diff --git a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts index d45c6cc4e0..99f71f7de0 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -2,7 +2,9 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; import { Cause, + Clock, Deferred, + Duration, Effect, Exit, Fiber, @@ -11,10 +13,12 @@ import { Option, Path, Predicate, + Queue, Schema, Stdio, } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { TestClock } from "effect/testing"; import { YesFlag } from "../../../command-internal/global-flags.ts"; import { stripControlSequences } from "../../../shared/output/strip-control-sequences.ts"; @@ -757,12 +761,37 @@ describe("functions deploy", () => { function apiDeployHarness( handler: ApiHandler, args: ReadonlyArray = ["functions", "deploy", "hello-world", "--use-api"], + rateLimitNotices?: Queue.Enqueue, ) { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ handler }); const layer = Layer.mergeAll( buildTestRuntime({ - out, + out: + rateLimitNotices === undefined + ? out + : { + ...out, + layer: Layer.effect( + Output, + Effect.gen(function* () { + const output = yield* Output; + return { + ...output, + raw: (text: string, stream?: "stdout" | "stderr") => + output + .raw(text, stream) + .pipe( + Effect.tap(() => + text.startsWith("Rate limit exceeded") + ? Queue.offer(rateLimitNotices, text) + : Effect.void, + ), + ), + }; + }), + ).pipe(Layer.provide(out.layer)), + }, api, cliSettings: mockCommandSettings({ workdir: tempRoot.current }), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), @@ -994,6 +1023,85 @@ describe("functions deploy", () => { }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); + it.effect("gives up on a persistent rate limit after the command's backoff schedule", () => + Effect.gen(function* () { + const notices = yield* Queue.unbounded(); + const deployTimes: Array = []; + const { layer } = apiDeployHarness( + (request) => + request.method === "GET" + ? Effect.succeed(jsonResponse(request, 200, [])) + : Effect.gen(function* () { + deployTimes.push(yield* Clock.currentTimeMillis); + return HttpClientResponse.fromWeb(request, new Response("{}", { status: 429 })); + }), + undefined, + notices, + ); + + yield* Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + const deploy = yield* Effect.forkChild(functionsDeploy(baseFlags)); + for (const seconds of [1, 2, 4, 8, 16, 32, 32, 32]) { + expect(yield* Queue.take(notices)).toBe( + `Rate limit exceeded while deploying function hello-world. Retrying in ${seconds}s.\n`, + ); + yield* TestClock.adjust(Duration.seconds(seconds)); + } + const error = yield* Fiber.join(deploy).pipe(Effect.flip); + + expect(error).toBeInstanceOf(FunctionsApiStatusError); + expect(error).toMatchObject({ status: 429 }); + expect(deployTimes).toEqual([ + 0, 1_000, 3_000, 7_000, 15_000, 31_000, 63_000, 95_000, 127_000, + ]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }), + ); + + it.effect("waits for the server's retry-after before retrying a rate-limited deploy", () => + Effect.gen(function* () { + const notices = yield* Queue.unbounded(); + let deployAttempts = 0; + const { layer, api } = apiDeployHarness( + (request, recorded) => { + if (request.method === "GET") return Effect.succeed(jsonResponse(request, 200, [])); + deployAttempts += 1; + if (deployAttempts === 1) { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": "3" } }), + ), + ); + } + return deployedHelloWorld(request, recorded); + }, + undefined, + notices, + ); + const deployRequests = () => api.requests.filter((request) => request.method === "POST"); + + yield* Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + const deploy = yield* Effect.forkChild(functionsDeploy(baseFlags)); + expect(yield* Queue.take(notices)).toBe( + "Rate limit exceeded while deploying function hello-world. Retrying in 3s.\n", + ); + yield* TestClock.adjust(Duration.millis(2_999)); + expect(deployRequests()).toHaveLength(1); + yield* TestClock.adjust(Duration.millis(1)); + yield* Fiber.join(deploy); + + expect(deployRequests()).toHaveLength(2); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }), + ); + it.effect("reports a malformed functions list with the schema decoder's message", () => { const { layer } = apiDeployHarness((request) => Effect.succeed( diff --git a/apps/cli/src/shared/auth/jwks.integration.test.ts b/apps/cli/src/shared/auth/jwks.integration.test.ts index 69b641a2d5..d4644100d5 100644 --- a/apps/cli/src/shared/auth/jwks.integration.test.ts +++ b/apps/cli/src/shared/auth/jwks.integration.test.ts @@ -8,7 +8,7 @@ import { resolveRemoteJwks } from "./jwks.ts"; const issuer = "https://issuer.example"; describe("remote signing keys", () => { - it.live("discovers the key URL and preserves the provider's key payload", () => + it.effect("discovers the key URL and preserves the provider's key payload", () => Effect.gen(function* () { const requested: string[] = []; const keys = [{ kty: "RSA", kid: "key-1", custom: true }]; @@ -31,7 +31,7 @@ describe("remote signing keys", () => { { name: "missing discovery key URL", status: 200, body: {} }, { name: "empty key document", status: 200, body: { keys: [] } }, ]) { - it.live(`rejects ${response.name}`, () => + it.effect(`rejects ${response.name}`, () => Effect.gen(function* () { const client = HttpClient.make((request) => Effect.succeed( @@ -73,7 +73,7 @@ describe("remote signing keys", () => { }), ); - it.live("caller interruption aborts the owned request without becoming a remote error", () => + it.effect("caller interruption aborts the owned request without becoming a remote error", () => Effect.gen(function* () { const started = yield* Deferred.make(); const client = HttpClient.make((_request, _url, signal) => diff --git a/apps/cli/src/shared/cli/command-docs.ts b/apps/cli/src/shared/cli/command-docs.ts index 77abe75f1b..622fb23e02 100644 --- a/apps/cli/src/shared/cli/command-docs.ts +++ b/apps/cli/src/shared/cli/command-docs.ts @@ -1,11 +1,14 @@ import type { Command, HelpDoc } from "effect/cli"; +import { helpDocFor } from "./subcommand-flag-suggestions.ts"; // Get HelpDoc from a command (uses internal buildHelpDoc) export function getHelpDoc( command: Command.Command.Any, commandPath: ReadonlyArray, ): HelpDoc.HelpDoc { - return (command as any).buildHelpDoc(commandPath); + const helpDoc = helpDocFor(command, commandPath); + if (helpDoc === undefined) throw new Error(`Command ${command.name} has no help doc`); + return helpDoc; } // Navigate to a subcommand by path segments diff --git a/apps/cli/src/shared/cli/markdown-formatter.ts b/apps/cli/src/shared/cli/markdown-formatter.ts index fcf95b93e2..c1d08a2d20 100644 --- a/apps/cli/src/shared/cli/markdown-formatter.ts +++ b/apps/cli/src/shared/cli/markdown-formatter.ts @@ -13,10 +13,10 @@ function formatTable(headers: string[], rows: string[][]): string { const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length))); const pad = (s: string, w: number) => s + " ".repeat(w - s.length); - const headerLine = `| ${headers.map((h, i) => pad(h, widths[i]!)).join(" | ")} |`; + const headerLine = `| ${headers.map((h, i) => pad(h, widths[i] ?? h.length)).join(" | ")} |`; const separatorLine = `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`; const dataLines = rows.map( - (row) => `| ${row.map((cell, i) => pad(cell, widths[i]!)).join(" | ")} |`, + (row) => `| ${row.map((cell, i) => pad(cell, widths[i] ?? cell.length)).join(" | ")} |`, ); return [headerLine, separatorLine, ...dataLines].join("\n"); diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index fb3019f3e6..50777b266f 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -12,6 +12,7 @@ import { Layer, Path, PlatformError, + Predicate, Runtime, Scope, Stdio, @@ -123,7 +124,8 @@ const selfManagedSignalCommands: ReadonlyArray> = [["funct export function extractCommandPath(args: ReadonlyArray): ReadonlyArray { const commandArgs: Array = []; for (let index = 0; index < args.length; index += 1) { - const arg = args[index]!; + const arg = args[index]; + if (arg === undefined) continue; if (arg === "--") return commandArgs; if (arg.startsWith("-")) { const [flag] = arg.split("=", 1); @@ -157,7 +159,8 @@ export function* rootFlagTokens( isValueTakingToken: (token: string) => boolean = isGlobalValueFlagToken, ): Generator<{ readonly token: string; readonly index: number }> { for (let index = 0; index < args.length; index += 1) { - const arg = args[index]!; + const arg = args[index]; + if (arg === undefined) continue; if (arg === "--") return; if (!arg.startsWith("-")) continue; yield { token: arg, index }; @@ -211,7 +214,7 @@ function* shortClusterFlagNames( ): Generator { if (!token.startsWith("-") || token.startsWith("--")) return; for (let rest = token.slice(1); rest.length > 0; rest = rest.slice(1)) { - const short = `-${rest[0]!}`; + const short = `-${rest.charAt(0)}`; if (isValueTakingToken(short)) return; if (rest[1] === "=") { yield short; @@ -228,7 +231,7 @@ function shortClusterConsumesNextToken( ): boolean { if (!token.startsWith("-") || token.startsWith("--")) return false; for (let rest = token.slice(1); rest.length > 0; rest = rest.slice(1)) { - if (isValueTakingToken(`-${rest[0]!}`)) return rest.length === 1; + if (isValueTakingToken(`-${rest.charAt(0)}`)) return rest.length === 1; if (rest[1] === "=") return false; } return false; @@ -244,7 +247,8 @@ function firstPositionalIndex( isValueTakingToken: (token: string) => boolean, ): number { for (let index = 0; index < args.length; index += 1) { - const arg = args[index]!; + const arg = args[index]; + if (arg === undefined) continue; if (arg === "--") return index + 1; if (!arg.startsWith("-")) return index; const [flag] = arg.split("=", 1); @@ -452,7 +456,11 @@ export function classifyParseErrorConsoleOutput( context: CliErrorSuggestionContext, ): ParseErrorConsoleDisposition { const error = Cause.squash(cause); - if (!CliError.isCliError(error) || error._tag !== "ShowHelp" || error.errors.length === 0) { + if ( + !CliError.isCliError(error) || + !Predicate.isTagged(error, "ShowHelp") || + error.errors.length === 0 + ) { return "flush-unchanged"; } // `isValueTakingFlagTokenFor` only inspects the resolved leaf command's own flags, so @@ -463,7 +471,7 @@ export function classifyParseErrorConsoleOutput( const isValueTakingToken = (token: string) => globalFlagsWithValues.has(token) || isLeafValueTakingToken(token); const isSuppressedMissingFlag = (inner: (typeof error.errors)[number]) => - inner._tag === "MissingOption" && + Predicate.isTagged(inner, "MissingOption") && !isMissingFlagTokenPresent( inner.option, context.args, @@ -671,7 +679,7 @@ export const runCli = Effect.fnUntraced(function* < .pipe(Effect.map((signal) => ({ _tag: "signal" as const, signal }))), ); - if (outcome._tag === "signal") { + if (Predicate.isTagged(outcome, "signal")) { // SIGHUP must also stay held once cleanup begins. yield* Effect.scoped( processControl.holdSignals(["SIGHUP"]).pipe(Effect.andThen(Fiber.interrupt(cliFiber))), diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts index 4e2785e3d4..738955d71e 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -1,3 +1,4 @@ +import { Predicate } from "effect"; import type { CliError, Command, HelpDoc } from "effect/cli"; import { formatInvalidValueMessage } from "./invalid-value-message.ts"; @@ -36,7 +37,7 @@ function hasHelpDoc(command: Command.Command.Any): command is CommandWithHelpDoc return "buildHelpDoc" in command && typeof command.buildHelpDoc === "function"; } -function helpDocFor( +export function helpDocFor( command: Command.Command.Any, commandPath: ReadonlyArray, ): HelpDoc.HelpDoc | undefined { @@ -281,7 +282,7 @@ export function formatCliErrorsForDisplay( let changed = false; for (const error of errors) { - if (error._tag === "UnrecognizedOption" && context) { + if (Predicate.isTagged(error, "UnrecognizedOption") && context) { const hint = buildSubcommandFlagHint(error, context); if (hint) { if (hint.consumedValue) suppressedUnknownSubcommands.add(hint.consumedValue); @@ -296,12 +297,15 @@ export function formatCliErrorsForDisplay( } } - if (error._tag === "UnknownSubcommand" && suppressedUnknownSubcommands.has(error.subcommand)) { + if ( + Predicate.isTagged(error, "UnknownSubcommand") && + suppressedUnknownSubcommands.has(error.subcommand) + ) { changed = true; continue; } - if (error._tag === "InvalidValue") { + if (Predicate.isTagged(error, "InvalidValue")) { const message = formatInvalidValueMessage(error); if (message !== undefined) { changed = true; diff --git a/apps/cli/src/shared/cli/success-trailer.ts b/apps/cli/src/shared/cli/success-trailer.ts index 796976be31..766f5ce9f7 100644 --- a/apps/cli/src/shared/cli/success-trailer.ts +++ b/apps/cli/src/shared/cli/success-trailer.ts @@ -22,11 +22,7 @@ export const successTrailerLayer = Layer.effect( return SuccessTrailer.of({ append: (text) => Ref.update(pending, (trailers) => [...trailers, text]), - takeAll: Effect.gen(function* () { - const trailers = yield* Ref.get(pending); - yield* Ref.set(pending, empty); - return trailers; - }), + takeAll: Ref.getAndSet(pending, empty), setWorkingDirectory: (cwd) => Ref.set(workingDirectory, cwd), workingDirectory: Ref.get(workingDirectory), }); diff --git a/apps/cli/src/shared/cli/version.ts b/apps/cli/src/shared/cli/version.ts index 2ec9e2bea4..03e32100a0 100644 --- a/apps/cli/src/shared/cli/version.ts +++ b/apps/cli/src/shared/cli/version.ts @@ -28,7 +28,8 @@ export function parseSemver(version: string): ParsedSemver | undefined { /^(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)?(?![\s\S])/.exec( version, ); - if (match === null) return undefined; + const major = match?.[1]; + if (match === null || major === undefined) return undefined; const prerelease = match[4] ?? ""; if ( prerelease @@ -40,7 +41,7 @@ export function parseSemver(version: string): ParsedSemver | undefined { return undefined; } return { - nums: [match[1]!, match[2] ?? "0", match[3] ?? "0"], + nums: [major, match[2] ?? "0", match[3] ?? "0"], prerelease, }; } diff --git a/apps/cli/src/shared/compute/compute-config.unit.test.ts b/apps/cli/src/shared/compute/compute-config.unit.test.ts index a3d63065d8..56ffdb3240 100644 --- a/apps/cli/src/shared/compute/compute-config.unit.test.ts +++ b/apps/cli/src/shared/compute/compute-config.unit.test.ts @@ -126,7 +126,7 @@ describe("planComputeEntry + commitComputeEntry", () => { // The re-parse below is a syntax check, not a schema one: `instances = 1.5` // is perfectly valid TOML that the compute schema rejects, so it would reach // the user's config and only fail later, when the loader refuses the file. - it.live.each([ + it.effect.each([ { label: "a fraction", instances: 1.5 }, { label: "a negative count", instances: -1 }, { label: "a value past the safe integer range", instances: 1e21 }, @@ -150,7 +150,7 @@ describe("planComputeEntry + commitComputeEntry", () => { ), ); - it.live("writes a whole, non-negative count unquoted", () => + it.effect("writes a whole, non-negative count unquoted", () => withConfig((configPath, fs) => Effect.gen(function* () { yield* writeComputeEntry({ @@ -165,7 +165,7 @@ describe("planComputeEntry + commitComputeEntry", () => { ), ); - it.live("creates the file when there is none yet", () => + it.effect("creates the file when there is none yet", () => withConfig((configPath, fs) => Effect.gen(function* () { yield* writeComputeEntry({ @@ -180,7 +180,7 @@ describe("planComputeEntry + commitComputeEntry", () => { ), ); - it.live("appends to an existing file without touching the rest of it", () => + it.effect("appends to an existing file without touching the rest of it", () => withConfig((configPath, fs) => Effect.gen(function* () { yield* fs.writeFileString(configPath, '# keep me\nproject_id = "demo"\n'); @@ -201,7 +201,7 @@ describe("planComputeEntry + commitComputeEntry", () => { // `new` creates a compute; changing one that exists is a `config.toml` edit and // the file is the user's. Refusing is also what keeps writes append-only. - it.live("refuses a compute that is already configured, leaving the file alone", () => + it.effect("refuses a compute that is already configured, leaving the file alone", () => withConfig((configPath, fs) => Effect.gen(function* () { const before = '# hand-written\n[compute.api]\nruntime = "node" # mine\n'; @@ -226,7 +226,7 @@ describe("planComputeEntry + commitComputeEntry", () => { // decoded config says it exists, which is the whole question, and answering it // from the parser rather than the file text is what removed the need to know // any TOML beyond how to render a value. - it.live.each([ + it.effect.each([ { label: "dotted keys", before: 'compute.api.runtime = "node"\n' }, { label: "an inline table", before: 'compute = { api = { runtime = "node" } }\n' }, { label: "a value spanning lines", before: '[compute.api]\nruntime = [\n "node",\n]\n' }, @@ -258,7 +258,7 @@ describe("planComputeEntry + commitComputeEntry", () => { // `[compute.api]` renders a file nothing can parse. The name is absent from // the decoded section, so the already-configured check cannot catch this — // reading the rendered plan back is what does. - it.live.each([ + it.effect.each([ { label: "an empty inline compute table", before: "compute = {}\n" }, { label: "an inline compute table holding another compute", @@ -288,7 +288,7 @@ describe("planComputeEntry + commitComputeEntry", () => { // parse to begin with cannot be appended to safely either, and finding that // out after the scaffold is written is exactly what the plan/commit split // exists to avoid. - it.live("refuses a config.toml that does not parse, leaving the file alone", () => + it.effect("refuses a config.toml that does not parse, leaving the file alone", () => withConfig((configPath, fs) => Effect.gen(function* () { const before = "this is not = = toml\n"; @@ -312,7 +312,7 @@ describe("planComputeEntry + commitComputeEntry", () => { // Why rendering is separate from writing: `new` writes the starter files before // it records anything, so a failure that could only surface at the write would // leave a scaffold on disk that nothing records. - it.live("renders without writing, and only writes when committed", () => + it.effect("renders without writing, and only writes when committed", () => withConfig((configPath, fs) => Effect.gen(function* () { yield* fs.writeFileString(configPath, 'project_id = "demo"\n'); diff --git a/apps/cli/src/shared/compute/compute-package.compression.unit.test.ts b/apps/cli/src/shared/compute/compute-package.compression.unit.test.ts index 7355c851dd..ac5d93c82f 100644 --- a/apps/cli/src/shared/compute/compute-package.compression.unit.test.ts +++ b/apps/cli/src/shared/compute/compute-package.compression.unit.test.ts @@ -13,7 +13,7 @@ vi.mock("node:zlib", () => ({ })); describe("packageComputeDirectory compression", () => { - it.live("reports compression failures as typed errors with their cause", () => + it.effect("reports compression failures as typed errors with their cause", () => Effect.scoped( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/shared/compute/compute-package.unit.test.ts b/apps/cli/src/shared/compute/compute-package.unit.test.ts index 6a18940959..fe5f4303c1 100644 --- a/apps/cli/src/shared/compute/compute-package.unit.test.ts +++ b/apps/cli/src/shared/compute/compute-package.unit.test.ts @@ -64,7 +64,7 @@ describe("packageComputeDirectory", () => { const pack = (root: string) => packageComputeDirectory(root).pipe(Effect.provide(BunServices.layer)); - it.live("packages files and nested directories in a stable order", () => + it.effect("packages files and nested directories in a stable order", () => withTemp("supabase-compute-package-", (dir, fs, path) => Effect.gen(function* () { yield* fs.makeDirectory(path.join(dir, "nested")); @@ -88,7 +88,7 @@ describe("packageComputeDirectory", () => { // Anything pnpm installs is symlink-dense, so following links would inline // every dependency's real contents — and a link pointing at an ancestor would // be walked into until the OS refused. - it.live("stores symlinks as links rather than following them", () => + it.effect("stores symlinks as links rather than following them", () => withTemp("supabase-compute-package-", (dir, fs, path) => Effect.gen(function* () { yield* fs.writeFileString(path.join(dir, "target.txt"), "hello"); @@ -106,7 +106,7 @@ describe("packageComputeDirectory", () => { // Broken, but pointing at a name inside the tree: whether the target exists is // the server's problem once the archive is extracted, and dropping the link // would change the tree the build sees. - it.live("keeps a broken symlink instead of dropping it", () => + it.effect("keeps a broken symlink instead of dropping it", () => withTemp("supabase-compute-package-", (dir, fs, path) => Effect.gen(function* () { yield* fs.symlink("nowhere-at-all.txt", path.join(dir, "broken.txt")); @@ -121,7 +121,7 @@ describe("packageComputeDirectory", () => { // The archive is the whole of what the server gets, so a link out of it // arrives dangling however valid it is here. Refused while the user is still // at the terminal, rather than surfacing as a remote build failure. - it.live.each([ + it.effect.each([ { label: "a relative escape", target: "../../outside.txt" }, { label: "an absolute escape", target: "/nowhere-at-all" }, { @@ -155,7 +155,7 @@ describe("packageComputeDirectory", () => { // An absolute target that lands back inside the tree is a path on this // machine; stored verbatim it would resolve to nothing on the other end. - it.live("rewrites an absolute in-tree link target as a relative one", () => + it.effect("rewrites an absolute in-tree link target as a relative one", () => withTemp("supabase-compute-package-", (dir, fs, path) => Effect.gen(function* () { yield* fs.writeFileString(path.join(dir, "target.txt"), "t"); @@ -171,7 +171,7 @@ describe("packageComputeDirectory", () => { ), ); - it.live("does not recurse through a directory symlink that points at an ancestor", () => + it.effect("does not recurse through a directory symlink that points at an ancestor", () => withTemp("supabase-compute-package-", (dir, fs, path) => Effect.gen(function* () { yield* fs.makeDirectory(path.join(dir, "sub")); @@ -193,7 +193,7 @@ describe("packageComputeDirectory", () => { return yield* packageComputeDirectory(root, exclude); }).pipe(Effect.provide(BunServices.layer)); - it.live("leaves a matched file out of the archive", () => + it.effect("leaves a matched file out of the archive", () => withTemp("supabase-compute-package-", (dir, fs, path) => Effect.gen(function* () { yield* fs.writeFileString(path.join(dir, "index.js"), "x"); @@ -210,7 +210,7 @@ describe("packageComputeDirectory", () => { // The walk turns back at the directory, so nothing underneath is read at all — which is // the point for a dependency tree, and is also why the count is one rather than three. - it.live("leaves an excluded directory's whole subtree out, counted once", () => + it.effect("leaves an excluded directory's whole subtree out, counted once", () => withTemp("supabase-compute-package-", (dir, fs, path) => Effect.gen(function* () { yield* fs.writeFileString(path.join(dir, "index.js"), "x"); @@ -226,7 +226,7 @@ describe("packageComputeDirectory", () => { ), ); - it.live("matches an unanchored pattern at every depth", () => + it.effect("matches an unanchored pattern at every depth", () => withTemp("supabase-compute-package-", (dir, fs, path) => Effect.gen(function* () { yield* fs.makeDirectory(path.join(dir, "packages", "api"), { recursive: true }); @@ -244,7 +244,7 @@ describe("packageComputeDirectory", () => { ), ); - it.live("keeps a file an anchored pattern only matches elsewhere", () => + it.effect("keeps a file an anchored pattern only matches elsewhere", () => withTemp("supabase-compute-package-", (dir, fs, path) => Effect.gen(function* () { yield* fs.makeDirectory(path.join(dir, "nested")); @@ -263,7 +263,7 @@ describe("packageComputeDirectory", () => { // The link is excluded before it is vetted, so excluding it is a real answer to the // hoisted-dependency failure rather than something that failure pre-empts. - it.live("excludes a symlinked node_modules with the node runtime's defaults", () => + it.effect("excludes a symlinked node_modules with the node runtime's defaults", () => withTemp("supabase-compute-package-", (dir, fs, path) => Effect.gen(function* () { yield* fs.writeFileString(path.join(dir, "index.js"), "x"); @@ -277,7 +277,7 @@ describe("packageComputeDirectory", () => { ), ); - it.live("reports nothing excluded when no pattern matches", () => + it.effect("reports nothing excluded when no pattern matches", () => withTemp("supabase-compute-package-", (dir, fs, path) => Effect.gen(function* () { yield* fs.writeFileString(path.join(dir, "index.js"), "x"); @@ -296,7 +296,7 @@ describe("packageComputeDirectory", () => { // so it would sail past the width check and ship a header GNU tar rejects // after the upload. A botched `touch` is not worth failing a deploy over, so // the timestamp collapses to the epoch instead. - it.live("packages a file with a pre-epoch mtime, timestamped at the epoch", () => + it.effect("packages a file with a pre-epoch mtime, timestamped at the epoch", () => withTemp("supabase-compute-package-", (dir, fs, path) => Effect.gen(function* () { const file = path.join(dir, "a.txt"); @@ -318,7 +318,7 @@ describe("packageComputeDirectory", () => { ), ); - it.live("packages an empty directory to an archive with no entries", () => + it.effect("packages an empty directory to an archive with no entries", () => withTemp("supabase-compute-package-", (dir, _fs, _path) => Effect.gen(function* () { const result = yield* pack(dir); @@ -332,7 +332,7 @@ describe("packageComputeDirectory", () => { // Archiving an unreadable file as zero bytes would report success for a deploy // carrying an empty file. Failing is the only honest answer: the archive is the // application. - it.live("fails rather than archiving a file it cannot read as empty", () => + it.effect("fails rather than archiving a file it cannot read as empty", () => withTemp("supabase-compute-package-", (dir, fs, path) => Effect.gen(function* () { const unreadable = path.join(dir, "secret.txt"); @@ -362,7 +362,7 @@ describe("packageComputeDirectory", () => { ), ); - it.live("fails rather than silently dropping a directory it cannot read", () => + it.effect("fails rather than silently dropping a directory it cannot read", () => withTemp("supabase-compute-package-", (dir, fs, path) => Effect.gen(function* () { const locked = path.join(dir, "locked"); @@ -396,7 +396,7 @@ describe("packageComputeDirectory", () => { // Tar validation stays in the Effect failure channel, so structured output can // render an actionable error instead of treating it as an uncaught defect. describe("packageComputeDirectory tar limits", () => { - it.live("reports an unrepresentable path as a failure rather than a defect", () => + it.effect("reports an unrepresentable path as a failure rather than a defect", () => withTemp("supabase-compute-tar-", (dir, fs, path) => Effect.gen(function* () { // One component over 100 bytes, with no directory boundary to split on. @@ -426,7 +426,7 @@ describe("packageComputeDirectory tar limits", () => { // reaches the failure channel rather than being rethrown as a defect. An 8 GiB // file trips it through the size field; a far-future mtime is the same check // for the price of a `utimes` call. - it.live("reports an out-of-range header field as a failure rather than a defect", () => + it.effect("reports an out-of-range header field as a failure rather than a defect", () => withTemp("supabase-compute-tar-", (dir, fs, path) => Effect.gen(function* () { const file = path.join(dir, "a.txt"); diff --git a/apps/cli/src/shared/compute/compute-paths.unit.test.ts b/apps/cli/src/shared/compute/compute-paths.unit.test.ts index b7dafe06a5..20c13c1e3b 100644 --- a/apps/cli/src/shared/compute/compute-paths.unit.test.ts +++ b/apps/cli/src/shared/compute/compute-paths.unit.test.ts @@ -27,7 +27,7 @@ const runPath = (fn: (path: Path.Path) => A): Effect.Effect => }).pipe(Effect.provide(BunServices.layer)); describe("compute directories", () => { - it.live("resolve under supabase/compute/", () => + it.effect("resolve under supabase/compute/", () => Effect.gen(function* () { expect(yield* runPath((path) => computeRootDir(path, PROJECT))).toBe( yield* runPath((path) => path.join(PROJECT, "supabase", "compute")), @@ -38,7 +38,7 @@ describe("compute directories", () => { }), ); - it.live("a recorded source wins and is anchored to the project root", () => + it.effect("a recorded source wins and is anchored to the project root", () => Effect.gen(function* () { const defaultDir = yield* runPath((path) => computeDir(path, PROJECT, "api")); const sourceDir = (configuredSource: string | undefined) => @@ -56,7 +56,7 @@ describe("compute directories", () => { // `source` arrives from a committed `config.toml`, so it is as much an input // as `--source` is — and `push` packages and uploads whatever it resolves to. - it.live.each([ + it.effect.each([ { configuredSource: "../../elsewhere" }, { configuredSource: "/etc" }, { configuredSource: "supabase/functions/hello" }, @@ -79,7 +79,7 @@ describe("compute directories", () => { }); describe("displayPath", () => { - it.live("prefers the relative form, and falls back to absolute when it would climb out", () => + it.effect("prefers the relative form, and falls back to absolute when it would climb out", () => Effect.gen(function* () { expect( yield* runPath((path) => @@ -99,7 +99,7 @@ describe("displayPath", () => { describe("resolveComputeSource", () => { const cwd = `${PROJECT}/apps/web`; - it.live("resolves a directory inside the project against the directory it was typed in", () => + it.effect("resolves a directory inside the project against the directory it was typed in", () => Effect.gen(function* () { expect( yield* runFs( @@ -116,7 +116,7 @@ describe("resolveComputeSource", () => { // The starter files land in whatever this resolves to, so each of these would // write into work belonging to the project or to the machine. - it.live.each([ + it.effect.each([ { raw: ".", reason: "the project root itself" }, { raw: "", reason: "empty" }, { raw: "..", reason: "outside the project" }, @@ -173,7 +173,7 @@ describe("resolveComputeSource containment on a real filesystem", () => { }), ).pipe(Effect.provide(BunServices.layer)); - it.live("resolves a genuine directory inside the project", () => + it.effect("resolves a genuine directory inside the project", () => withFixture((project, _outside, _fs, path) => Effect.gen(function* () { expect( @@ -185,7 +185,7 @@ describe("resolveComputeSource containment on a real filesystem", () => { ), ); - it.live("refuses a path that reaches outside the project through a symlink", () => + it.effect("refuses a path that reaches outside the project through a symlink", () => withFixture((project, outside, fs, path) => Effect.gen(function* () { yield* fs.symlink(outside, path.join(project, "packages", "external")); @@ -206,7 +206,7 @@ describe("resolveComputeSource containment on a real filesystem", () => { ), ); - it.live("refuses a reserved directory reached through a symlink", () => + it.effect("refuses a reserved directory reached through a symlink", () => withFixture((project, _outside, fs, path) => Effect.gen(function* () { yield* fs.symlink(path.join(project, "supabase", "functions"), path.join(project, "fns")); @@ -233,7 +233,7 @@ describe("resolveComputeSource containment on a real filesystem", () => { // A name that ends in a space is legal on Unix, and only reaches argv as one // entry if the user quoted it. Trimming it pointed the scaffold at a different // directory than the one asked for. - it.live("keeps whitespace that is part of the directory name", () => + it.effect("keeps whitespace that is part of the directory name", () => withFixture((project, _outside, _fs, path) => Effect.gen(function* () { expect( @@ -245,7 +245,7 @@ describe("resolveComputeSource containment on a real filesystem", () => { ), ); - it.live.each([{ raw: "" }, { raw: " " }, { raw: "\t" }])( + it.effect.each([{ raw: "" }, { raw: " " }, { raw: "\t" }])( "refuses an all-whitespace --source of $raw", ({ raw }) => withFixture((project, _outside, _fs, _path) => @@ -259,7 +259,7 @@ describe("resolveComputeSource containment on a real filesystem", () => { ), ); - it.live("accepts a destination that does not exist yet", () => + it.effect("accepts a destination that does not exist yet", () => withFixture((project, _outside, _fs, path) => Effect.gen(function* () { expect( diff --git a/apps/cli/src/shared/compute/compute-stacks.integration.test.ts b/apps/cli/src/shared/compute/compute-stacks.integration.test.ts index 9aa25543a4..4ee9c076b6 100644 --- a/apps/cli/src/shared/compute/compute-stacks.integration.test.ts +++ b/apps/cli/src/shared/compute/compute-stacks.integration.test.ts @@ -40,7 +40,7 @@ function expectValidationFailure(exit: Exit.Exit, message: string): expect(failure.value.message).toContain(message); } -it.live("reports offered runtimes without starter directories", () => +it.effect("reports offered runtimes without starter directories", () => Effect.scoped( Effect.gen(function* () { const root = yield* stackFixture({ @@ -54,7 +54,7 @@ it.live("reports offered runtimes without starter directories", () => ).pipe(Effect.provide(BunServices.layer)), ); -it.live("reports directories without offered runtimes", () => +it.effect("reports directories without offered runtimes", () => Effect.scoped( Effect.gen(function* () { const root = yield* stackFixture({ @@ -70,7 +70,7 @@ it.live("reports directories without offered runtimes", () => ).pipe(Effect.provide(BunServices.layer)), ); -it.live("reports an offered runtime with no starter files", () => +it.effect("reports an offered runtime with no starter files", () => Effect.scoped( Effect.gen(function* () { const root = yield* stackFixture({ diff --git a/apps/cli/src/shared/compute/tar.unit.test.ts b/apps/cli/src/shared/compute/tar.unit.test.ts index 5f79f63b60..159530903f 100644 --- a/apps/cli/src/shared/compute/tar.unit.test.ts +++ b/apps/cli/src/shared/compute/tar.unit.test.ts @@ -16,7 +16,7 @@ function value(archive: Uint8Array, block: number, offset: number, length: numbe } describe("createTar", () => { - it.live("writes a readable ustar header for a file", () => + it.effect("writes a readable ustar header for a file", () => Effect.gen(function* () { const archive = yield* createTar([ { path: "index.js", contents: encoder.encode("hello"), mode: 0o644, mtime: 1_700_000_000 }, @@ -31,7 +31,7 @@ describe("createTar", () => { }), ); - it.live("computes a checksum the standard algorithm reproduces", () => + it.effect("computes a checksum the standard algorithm reproduces", () => Effect.gen(function* () { const archive = yield* createTar([{ path: "a.txt", contents: encoder.encode("a") }]); const header = archive.subarray(0, 512); @@ -46,7 +46,7 @@ describe("createTar", () => { }), ); - it.live("pads content to a 512-byte boundary and ends with two zero blocks", () => + it.effect("pads content to a 512-byte boundary and ends with two zero blocks", () => Effect.gen(function* () { const archive = yield* createTar([{ path: "a.txt", contents: encoder.encode("hello") }]); @@ -56,7 +56,7 @@ describe("createTar", () => { }), ); - it.live("emits directory entries with no content and the directory typeflag", () => + it.effect("emits directory entries with no content and the directory typeflag", () => Effect.gen(function* () { const archive = yield* createTar([ { path: "nested/", contents: new Uint8Array(0), mode: 0o755 }, @@ -69,7 +69,7 @@ describe("createTar", () => { }), ); - it.live("stores a symlink as a link entry with no content blocks", () => + it.effect("stores a symlink as a link entry with no content blocks", () => Effect.gen(function* () { const archive = yield* createTar([ { path: "link.txt", contents: new Uint8Array(0), linkTarget: "target.txt", mode: 0o777 }, @@ -82,7 +82,7 @@ describe("createTar", () => { }), ); - it.live("a symlink entry wins over the trailing-slash directory rule", () => + it.effect("a symlink entry wins over the trailing-slash directory rule", () => Effect.gen(function* () { const archive = yield* createTar([ { path: "dir", contents: new Uint8Array(0), linkTarget: ".." }, @@ -91,7 +91,7 @@ describe("createTar", () => { }), ); - it.live("refuses a link target too long for the header field", () => + it.effect("refuses a link target too long for the header field", () => Effect.gen(function* () { const error = yield* Effect.flip( createTar([ @@ -102,7 +102,7 @@ describe("createTar", () => { }), ); - it.live("splits a long path across the prefix and name fields", () => + it.effect("splits a long path across the prefix and name fields", () => Effect.gen(function* () { const deep = `${"d".repeat(120)}/${"f".repeat(60)}.txt`; const archive = yield* createTar([{ path: deep, contents: new Uint8Array(0) }]); @@ -112,7 +112,7 @@ describe("createTar", () => { }), ); - it.live("refuses a value too large for an octal header field rather than truncating it", () => + it.effect("refuses a value too large for an octal header field rather than truncating it", () => Effect.gen(function* () { const error = yield* Effect.flip( createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime: 8 ** 11 }]), @@ -123,7 +123,7 @@ describe("createTar", () => { }), ); - it.live("can evaluate one archive Effect more than once", () => + it.effect("can evaluate one archive Effect more than once", () => Effect.gen(function* () { const archive = createTar([{ path: "a.txt", contents: encoder.encode("a") }]); const first = yield* archive; @@ -134,7 +134,7 @@ describe("createTar", () => { }), ); - it.live.each([ + it.effect.each([ { label: "a pre-epoch mtime", mtime: -1 }, { label: "an mtime from an invalid date", mtime: Number.NaN }, { label: "an infinite mtime", mtime: Number.POSITIVE_INFINITY }, @@ -147,7 +147,7 @@ describe("createTar", () => { }), ); - it.live("refuses a negative mode rather than writing a field no tar can parse", () => + it.effect("refuses a negative mode rather than writing a field no tar can parse", () => Effect.gen(function* () { const error = yield* Effect.flip( createTar([{ path: "a.txt", contents: new Uint8Array(1), mode: -1 }]), @@ -156,7 +156,7 @@ describe("createTar", () => { }), ); - it.live("refuses a path component too long to represent", () => + it.effect("refuses a path component too long to represent", () => Effect.gen(function* () { const error = yield* Effect.flip( createTar([{ path: `${"f".repeat(120)}.txt`, contents: new Uint8Array(0) }]), diff --git a/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts b/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts index f10c95ee46..b3e0c4988f 100644 --- a/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts +++ b/apps/cli/src/shared/config/cli-project-context.layer.unit.test.ts @@ -23,7 +23,7 @@ function buildLayer(path: Path.Path, opts: { cwd: string; env?: Record { - it.live("loads when supabase/config.toml uses env() on numeric fields (CLI-1489)", () => + it.effect("loads when supabase/config.toml uses env() on numeric fields (CLI-1489)", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -69,7 +69,7 @@ describe("cliProjectContextLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("returns empty context when no supabase project is found", () => + it.effect("returns empty context when no supabase project is found", () => Effect.gen(function* () { const path = yield* Path.Path; const tempDir = yield* makeTempDir; diff --git a/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts b/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts index e2843f0400..4257239d8c 100644 --- a/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts +++ b/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts @@ -49,7 +49,7 @@ function buildLayer( } describe("cliProjectHomeLayer", () => { - it.live("resolves a repo-local project home from the nearest discovered config root", () => + it.effect("resolves a repo-local project home from the nearest discovered config root", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -83,7 +83,7 @@ describe("cliProjectHomeLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("falls back to the nearest linked project root when no project config exists", () => + it.effect("falls back to the nearest linked project root when no project config exists", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -108,7 +108,7 @@ describe("cliProjectHomeLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("does not let a bare ancestor .supabase directory capture a nested checkout", () => + it.effect("does not let a bare ancestor .supabase directory capture a nested checkout", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -131,7 +131,7 @@ describe("cliProjectHomeLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("creates the repo-local .supabase directory lazily", () => + it.effect("creates the repo-local .supabase directory lazily", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -150,7 +150,7 @@ describe("cliProjectHomeLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails with CliProjectHomeNotDirectoryError when a FILE occupies the .supabase path", () => Effect.gen(function* () { @@ -182,7 +182,7 @@ describe("cliProjectHomeLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "fails with CliProjectHomeNotDirectoryError (BadResource) when a FILE occupies an ancestor of the project home path", () => // Distinct from the AlreadyExists case above: here `.supabase` doesn't exist, but a file diff --git a/apps/cli/src/shared/config/cli-settings.layer.unit.test.ts b/apps/cli/src/shared/config/cli-settings.layer.unit.test.ts index 62e409f354..20324cac30 100644 --- a/apps/cli/src/shared/config/cli-settings.layer.unit.test.ts +++ b/apps/cli/src/shared/config/cli-settings.layer.unit.test.ts @@ -59,7 +59,7 @@ function buildLayer( describe("cliSettingsLayer", () => { for (const optOut of ["SUPABASE_TELEMETRY_DISABLED", "DO_NOT_TRACK"]) { - it.live(`honors injected ${optOut} alongside discovered project settings`, () => + it.effect(`honors injected ${optOut} alongside discovered project settings`, () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -83,7 +83,7 @@ describe("cliSettingsLayer", () => { ); } - it.live("falls back to ambient env when no Supabase project is found", () => + it.effect("falls back to ambient env when no Supabase project is found", () => Effect.gen(function* () { const path = yield* Path.Path; const tempDir = yield* makeTempDir; @@ -106,7 +106,7 @@ describe("cliSettingsLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "uses the nearest discovered project and loads supabase/.env.local over supabase/.env", () => Effect.gen(function* () { @@ -157,7 +157,7 @@ describe("cliSettingsLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("lets ambient env override discovered project env", () => + it.effect("lets ambient env override discovered project env", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -198,7 +198,7 @@ describe("cliSettingsLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("has no PostHog key when nothing is injected or overridden", () => + it.effect("has no PostHog key when nothing is injected or overridden", () => Effect.gen(function* () { const path = yield* Path.Path; const tempDir = yield* makeTempDir; @@ -237,7 +237,7 @@ describe("cliSettingsLayer", () => { }).pipe(Effect.provide(settingsLayer)); }); - it.live("prefers SUPABASE_TELEMETRY_POSTHOG_KEY over the shipped default", () => + it.effect("prefers SUPABASE_TELEMETRY_POSTHOG_KEY over the shipped default", () => Effect.gen(function* () { const path = yield* Path.Path; const tempDir = yield* makeTempDir; @@ -256,7 +256,7 @@ describe("cliSettingsLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("uses SUPABASE_HOME (trimmed) when configured", () => + it.effect("uses SUPABASE_HOME (trimmed) when configured", () => Effect.gen(function* () { const path = yield* Path.Path; const tempDir = yield* makeTempDir; @@ -272,7 +272,7 @@ describe("cliSettingsLayer", () => { ); for (const value of ["", " "]) { - it.live( + it.effect( `falls back to /.supabase when SUPABASE_HOME is ${JSON.stringify(value)}`, () => Effect.gen(function* () { @@ -290,7 +290,7 @@ describe("cliSettingsLayer", () => { ); } - it.live("uses the build-injected PostHog key and host when no runtime override is set", () => + it.effect("uses the build-injected PostHog key and host when no runtime override is set", () => Effect.gen(function* () { const path = yield* Path.Path; const tempDir = yield* makeTempDir; diff --git a/apps/cli/src/shared/config/project-link-state.layer.ts b/apps/cli/src/shared/config/project-link-state.layer.ts index b8f292f601..d43cb17f91 100644 --- a/apps/cli/src/shared/config/project-link-state.layer.ts +++ b/apps/cli/src/shared/config/project-link-state.layer.ts @@ -42,16 +42,15 @@ const makeProjectLinkState = Effect.gen(function* () { const load = loadFromPath(cliProjectHome.projectLinkPath); - const save = (state: ProjectLinkStateValue) => - Effect.gen(function* () { - yield* cliProjectHome.ensureCliProjectHomeDir; - const encoded = yield* encodeProjectLinkStateValue(state).pipe( - Effect.mapError(() => invalidProjectLinkStateError(cliProjectHome.projectLinkPath)), - ); - yield* fs.writeFileString(cliProjectHome.projectLinkPath, encodePrettyJson(encoded), { - mode: 0o600, - }); + const save = Effect.fn("ProjectLinkState.save")(function* (state: ProjectLinkStateValue) { + yield* cliProjectHome.ensureCliProjectHomeDir; + const encoded = yield* encodeProjectLinkStateValue(state).pipe( + Effect.mapError(() => invalidProjectLinkStateError(cliProjectHome.projectLinkPath)), + ); + yield* fs.writeFileString(cliProjectHome.projectLinkPath, encodePrettyJson(encoded), { + mode: 0o600, }); + }); const clear = fs .remove(cliProjectHome.projectLinkPath) @@ -63,17 +62,18 @@ const makeProjectLinkState = Effect.gen(function* () { const getActiveBranch = load.pipe(Effect.map(Option.map((state) => state.active_branch))); - const setActiveBranch = (branch: ActiveBranch) => - Effect.gen(function* () { - const current = yield* load; - if (Option.isNone(current)) { - 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 }); - }); + const setActiveBranch = Effect.fn("ProjectLinkState.setActiveBranch")(function* ( + branch: ActiveBranch, + ) { + const current = yield* load; + if (Option.isNone(current)) { + 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 }); + }); return ProjectLinkState.of({ load, diff --git a/apps/cli/src/shared/config/project-link-state.layer.unit.test.ts b/apps/cli/src/shared/config/project-link-state.layer.unit.test.ts index 3e2a497c00..06bef4e1a9 100644 --- a/apps/cli/src/shared/config/project-link-state.layer.unit.test.ts +++ b/apps/cli/src/shared/config/project-link-state.layer.unit.test.ts @@ -95,7 +95,7 @@ const SAMPLE_STATE = { } as const; describe("projectLinkStateLayer", () => { - it.live("surfaces a clear permission failure", () => + it.effect("surfaces a clear permission failure", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -132,7 +132,7 @@ describe("projectLinkStateLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("saves and loads repo-local project link state", () => + it.effect("saves and loads repo-local project link state", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -166,7 +166,7 @@ describe("projectLinkStateLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("clears repo-local link state", () => + it.effect("clears repo-local link state", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -194,7 +194,7 @@ describe("projectLinkStateLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("fails with a tagged error when repo-local link state is malformed", () => + it.effect("fails with a tagged error when repo-local link state is malformed", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -230,7 +230,7 @@ describe("projectLinkStateLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("getActiveBranch returns none when not linked", () => + it.effect("getActiveBranch returns none when not linked", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -248,7 +248,7 @@ describe("projectLinkStateLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("getActiveBranch returns the persisted active_branch", () => + it.effect("getActiveBranch returns the persisted active_branch", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -275,7 +275,7 @@ describe("projectLinkStateLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "setActiveBranch updates only active_branch, leaving project and versions unchanged", () => Effect.gen(function* () { @@ -306,7 +306,7 @@ describe("projectLinkStateLayer", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("setActiveBranch fails with ProjectNotLinkedError when project is not linked", () => + it.effect("setActiveBranch fails with ProjectNotLinkedError when project is not linked", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts index ed0c55277b..eb9bbe3508 100644 --- a/apps/cli/src/shared/feedback/feedback-client.integration.test.ts +++ b/apps/cli/src/shared/feedback/feedback-client.integration.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Deferred, Effect, Exit, Fiber, Schema } from "effect"; import { feedbackClientLayer } from "./feedback-client.layer.ts"; import type { FeedbackSubmission } from "./feedback-client.service.ts"; -import { FeedbackClient } from "./feedback-client.service.ts"; +import { FeedbackBackendError, FeedbackClient } from "./feedback-client.service.ts"; import { actionability, ErrorActionabilityId } from "../telemetry/error-actionability.ts"; // The layer only needs a url/key shape — no request leaves the test, so the @@ -72,7 +72,7 @@ function layerWith(transport: ReturnType) { describe("feedbackClientLayer", () => { describe("submit", () => { - it.live("submits through the RPC and returns the server-issued delete token", () => { + it.effect("submits through the RPC and returns the server-issued delete token", () => { const transport = recordingFetch(() => jsonResponse(TOKEN)); return Effect.gen(function* () { const client = yield* FeedbackClient; @@ -101,7 +101,7 @@ describe("feedbackClientLayer", () => { }).pipe(Effect.provide(layerWith(transport))); }); - it.live("omits project_ref and agent_name for an unlinked non-agent run", () => { + it.effect("omits project_ref and agent_name for an unlinked non-agent run", () => { const transport = recordingFetch(() => jsonResponse(TOKEN)); return Effect.gen(function* () { const client = yield* FeedbackClient; @@ -131,7 +131,7 @@ describe("feedbackClientLayer", () => { }).pipe(Effect.provide(layerWith(transport))); }); - it.live("maps a PostgREST error response to FeedbackBackendError", () => { + it.effect("maps a PostgREST error response to FeedbackBackendError", () => { // Shape PostgREST returns when execute is denied on the RPC. const transport = recordingFetch(() => jsonResponse( @@ -148,7 +148,7 @@ describe("feedbackClientLayer", () => { const client = yield* FeedbackClient; const error = yield* client.submit(SUBMISSION).pipe(Effect.flip); - expect(error._tag).toBe("FeedbackBackendError"); + expect(error).toBeInstanceOf(FeedbackBackendError); expect(error.operation).toBe("submit"); expect(error.message).toContain("permission denied"); // The backend answered: an API-status failure, not a network outage. @@ -160,7 +160,7 @@ describe("feedbackClientLayer", () => { }).pipe(Effect.provide(layerWith(transport))); }); - it.live("maps a network failure to FeedbackBackendError", () => { + it.effect("maps a network failure to FeedbackBackendError", () => { const transport = recordingFetch(() => { throw new Error("network down"); }); @@ -168,7 +168,7 @@ describe("feedbackClientLayer", () => { const client = yield* FeedbackClient; const error = yield* client.submit(SUBMISSION).pipe(Effect.flip); - expect(error._tag).toBe("FeedbackBackendError"); + expect(error).toBeInstanceOf(FeedbackBackendError); expect(error.operation).toBe("submit"); // The fetch threw: a transport failure, classified as network. expect(error.reason).toBe("transport"); @@ -176,7 +176,7 @@ describe("feedbackClientLayer", () => { }).pipe(Effect.provide(layerWith(transport))); }); - it.live("aborts the in-flight request when the fiber is interrupted", () => { + it.effect("aborts the in-flight request when the fiber is interrupted", () => { // Ctrl-C during submission must abort the HTTP request, not let the // insert commit after the command was cancelled. The fake fetch behaves // like a real one: it settles only when its abort signal fires. @@ -199,20 +199,20 @@ describe("feedbackClientLayer", () => { }).pipe(Effect.provide(layerWith(transport))); }); - it.live("fails when the backend returns no delete token", () => { + it.effect("fails when the backend returns no delete token", () => { const transport = recordingFetch(() => jsonResponse(null)); return Effect.gen(function* () { const client = yield* FeedbackClient; const error = yield* client.submit(SUBMISSION).pipe(Effect.flip); - expect(error._tag).toBe("FeedbackBackendError"); + expect(error).toBeInstanceOf(FeedbackBackendError); expect(error.message).toContain("no delete token"); }).pipe(Effect.provide(layerWith(transport))); }); }); describe("delete", () => { - it.live("deletes with the token filter, capability headers, and an exact count", () => { + it.effect("deletes with the token filter, capability headers, and an exact count", () => { const transport = recordingFetch( () => new Response(null, { status: 204, headers: { "content-range": "*/1" } }), ); @@ -233,7 +233,7 @@ describe("feedbackClientLayer", () => { }).pipe(Effect.provide(layerWith(transport))); }); - it.live("reports deleted: false when the delete matched zero rows", () => { + it.effect("reports deleted: false when the delete matched zero rows", () => { // Wrong/stale token or a project-ref/user-id context mismatch: RLS // matches nothing and PostgREST reports an empty range. const transport = recordingFetch( @@ -249,7 +249,7 @@ describe("feedbackClientLayer", () => { }).pipe(Effect.provide(layerWith(transport))); }); - it.live("maps a network failure to FeedbackBackendError", () => { + it.effect("maps a network failure to FeedbackBackendError", () => { const transport = recordingFetch(() => { throw new Error("network down"); }); @@ -257,7 +257,7 @@ describe("feedbackClientLayer", () => { const client = yield* FeedbackClient; const error = yield* client.delete(TOKEN).pipe(Effect.flip); - expect(error._tag).toBe("FeedbackBackendError"); + expect(error).toBeInstanceOf(FeedbackBackendError); expect(error.operation).toBe("delete"); }).pipe(Effect.provide(layerWith(transport))); }); diff --git a/apps/cli/src/shared/feedback/feedback-client.layer.ts b/apps/cli/src/shared/feedback/feedback-client.layer.ts index d77df67722..a22c675391 100644 --- a/apps/cli/src/shared/feedback/feedback-client.layer.ts +++ b/apps/cli/src/shared/feedback/feedback-client.layer.ts @@ -128,25 +128,22 @@ export function feedbackClientLayer(options: FeedbackClientOptions): Layer.Layer ); return FeedbackClient.of({ - submit: (submission) => - run("submit", (signal) => + submit: Effect.fn("FeedbackClient.submit")(function* (submission) { + const { data } = yield* run("submit", (signal) => client.rpc("submit_interfaces_feedback", toRpcArgs(submission)).abortSignal(signal), - ).pipe( - Effect.flatMap(({ data }) => - typeof data === "string" && data.length > 0 - ? Effect.succeed({ deleteToken: data }) - : Effect.fail( - new FeedbackBackendError({ - message: "feedback backend returned no delete token", - operation: "submit", - reason: "response", - }), - ), - ), - ), + ); + if (typeof data === "string" && data.length > 0) { + return { deleteToken: data }; + } + return yield* new FeedbackBackendError({ + message: "feedback backend returned no delete token", + operation: "submit", + reason: "response", + }); + }), - delete: (token, context) => - run("delete", (signal) => { + delete: Effect.fn("FeedbackClient.delete")(function* (token, context) { + const { count } = yield* run("delete", (signal) => { // The `delete_token=eq.` filter satisfies PostgREST's filterless-delete // rejection; the `x-feedback-token` header is the actual security // boundary (RLS matches zero rows without it). `count: "exact"` asks @@ -165,7 +162,9 @@ export function feedbackClientLayer(options: FeedbackClientOptions): Layer.Layer request = request.setHeader("x-feedback-user-id", context.userId); } return request; - }).pipe(Effect.map(({ count }) => ({ deleted: count === 1 }))), + }); + return { deleted: count === 1 }; + }), }); }); } diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index f6e3edd116..393f3538cd 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -12,7 +12,6 @@ import { } from "@supabase/config/effect"; import { Cause, - Clock, Config, Duration, Effect, @@ -21,6 +20,8 @@ import { Option, Path, type PlatformError, + Ref, + Schedule, Schema, } from "effect"; import * as HttpBody from "effect/http/HttpBody"; @@ -1692,54 +1693,37 @@ const listRemoteFunctions = Effect.fn("functions.deploy.listRemoteFunctions")(fu api: ApiClient, projectRef: string, ) { - let lastError: Error | FunctionsApiStatusError | undefined; - for (let attempt = 0; attempt <= 3; attempt += 1) { - const result = yield* api + return yield* Effect.gen(function* () { + const response = yield* api .executeRaw(operationDefinitions.v1ListAllFunctions, { ref: projectRef }) - .pipe( - Effect.map((response) => ({ success: true as const, response })), - Effect.catch((error) => - Effect.succeed({ - success: false as const, - error: mapTransportError("failed to list functions", error), - }), + .pipe(Effect.mapError((error) => mapTransportError("failed to list functions", error))); + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + if (response.status === 200) { + // A 200 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* decodeFunctionListResponse(body).pipe( + Effect.mapError( + (error) => + new FunctionsApiStatusError({ + status: response.status, + message: `failed to read functions list: ${error.message}`, + decode: true, + }), ), ); - - if (result.success) { - const body = yield* result.response.text.pipe(Effect.orElseSucceed(() => "")); - if (result.response.status === 200) { - // A 200 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* decodeFunctionListResponse(body).pipe( - Effect.mapError( - (error) => - new FunctionsApiStatusError({ - status: result.response.status, - message: `failed to read functions list: ${error.message}`, - decode: true, - }), - ), - ); - } - lastError = new FunctionsApiStatusError({ - status: result.response.status, - message: `unexpected list functions status ${result.response.status}: ${body}`, - }); - if (result.response.status < 500 && result.response.status !== 429) { - return yield* Effect.fail(lastError); - } - } else { - lastError = result.error; - } - - if (attempt < 3) { - yield* Effect.sleep(Duration.millis(1_000 * 2 ** attempt)); } - } - return yield* Effect.fail( - lastError ?? new FunctionDeployError({ message: "failed to list functions" }), + return yield* new FunctionsApiStatusError({ + status: response.status, + message: `unexpected list functions status ${response.status}: ${body}`, + }); + }).pipe( + Effect.retry({ + schedule: Schedule.exponential(Duration.seconds(1)), + times: 3, + while: (error) => + !(error instanceof FunctionsApiStatusError) || error.status >= 500 || error.status === 429, + }), ); }); @@ -1793,18 +1777,23 @@ const rateLimitedRequest = Effect.fnUntraced(function* ( >, ) { const output = yield* Output; - for (let attempt = 0; ; attempt += 1) { - const response = yield* request(); - if (response.status !== 429 || attempt >= DEPLOY_RATE_LIMIT_MAX_RETRIES) { - return response; - } - const delayMs = rateLimitDelayMillis(response.headers, attempt, yield* Clock.currentTimeMillis); - yield* output.raw( - `Rate limit exceeded while ${action}. Retrying in ${rateLimitDelayText(delayMs)}.\n`, - "stderr", - ); - yield* Effect.sleep(Duration.millis(delayMs)); - } + return yield* Effect.repeat(Effect.suspend(request), ($) => + $(Schedule.forever).pipe( + Schedule.passthrough, + Schedule.while( + ({ input, attempt }) => input.status === 429 && attempt <= DEPLOY_RATE_LIMIT_MAX_RETRIES, + ), + Schedule.modifyDelay(({ input, attempt, now }) => + Effect.succeed(Duration.millis(rateLimitDelayMillis(input.headers, attempt - 1, now))), + ), + Schedule.tap(({ duration }) => + output.raw( + `Rate limit exceeded while ${action}. Retrying in ${rateLimitDelayText(Duration.toMillis(duration))}.\n`, + "stderr", + ), + ), + ), + ); }); const uploadFunctionSource = Effect.fn("functions.deploy.uploadFunctionSource")(function* ( @@ -1922,9 +1911,8 @@ const bulkUpdateRemoteFunctions = Effect.fn("functions.deploy.bulkUpdateFunction projectRef: string, functions: ReadonlyArray, ) { - let lastError: Error | FunctionsApiStatusError | undefined; - for (let attempt = 0; attempt <= 3; attempt += 1) { - const result = yield* rateLimitedRequest("bulk updating functions", () => + return yield* Effect.gen(function* () { + const response = yield* rateLimitedRequest("bulk updating functions", () => api .executeRaw( operationDefinitions.v1BulkUpdateFunctions, @@ -1944,38 +1932,21 @@ const bulkUpdateRemoteFunctions = Effect.fn("functions.deploy.bulkUpdateFunction })), Effect.mapError((error) => mapTransportError("failed to bulk update", error)), ), - ).pipe( - Effect.map((response) => ({ success: true as const, response })), - Effect.catch((error) => - Effect.succeed({ - success: false as const, - error, - }), - ), ); - - if (result.success) { - const body = yield* result.response.body; - if (result.response.status === 200) { - return; - } - lastError = new FunctionsApiStatusError({ - status: result.response.status, - message: `unexpected bulk update status ${result.response.status}: ${body}`, - }); - if (result.response.status < 500) { - return yield* Effect.fail(lastError); - } - } else { - lastError = result.error; - } - - if (attempt < 3) { - yield* Effect.sleep(Duration.millis(1_000 * 2 ** attempt)); + const body = yield* response.body; + if (response.status === 200) { + return; } - } - return yield* Effect.fail( - lastError ?? new FunctionDeployError({ message: "failed to bulk update" }), + return yield* new FunctionsApiStatusError({ + status: response.status, + message: `unexpected bulk update status ${response.status}: ${body}`, + }); + }).pipe( + Effect.retry({ + schedule: Schedule.exponential(Duration.seconds(1)), + times: 3, + while: (error) => !(error instanceof FunctionsApiStatusError) || error.status >= 500, + }), ); }); @@ -1985,10 +1956,10 @@ const upsertBundledFunction = Effect.fn("functions.deploy.upsertFunction")(funct bundled: BundledFunction, exists: boolean, ) { - let shouldUpdate = exists; - let lastError: Error | FunctionsApiStatusError | undefined; + const shouldUpdateRef = yield* Ref.make(exists); - for (let attempt = 0; attempt <= 3; attempt += 1) { + const upsert = Effect.gen(function* () { + const shouldUpdate = yield* Ref.get(shouldUpdateRef); const action = shouldUpdate ? "update" : "create"; const updateInput = { ref: projectRef, @@ -2014,54 +1985,44 @@ const upsertBundledFunction = Effect.fn("functions.deploy.upsertFunction")(funct }) : api.executeRaw(operationDefinitions.v1CreateAFunction, createInput); const response = yield* request.pipe( - Effect.map((value) => ({ success: true as const, value })), - Effect.catch((error) => - Effect.succeed({ - success: false as const, - error: mapTransportError(`failed to ${action} function`, error), - }), - ), + Effect.mapError((error) => mapTransportError(`failed to ${action} function`, error)), ); - if (response.success) { - const expectedStatus = shouldUpdate ? 200 : 201; - if (response.value.status === expectedStatus) { - // A success status with a malformed / unexpected JSON body is an - // API-response problem, not a transport failure — surface it via - // FunctionsApiStatusError so it classifies as api_status not network. - const body = yield* response.value.text.pipe(Effect.orElseSucceed(() => "")); - return yield* decodeDeployFunctionResponse(body).pipe( - Effect.mapError( - (error) => - new FunctionsApiStatusError({ - status: response.value.status, - message: `failed to read function response: ${error.message}`, - decode: true, - }), - ), - ); - } - - const body = yield* response.value.text.pipe(Effect.orElseSucceed(() => "")); - if (!shouldUpdate && body.includes("Duplicated function slug")) { - shouldUpdate = true; - } - lastError = new FunctionsApiStatusError({ - status: response.value.status, - message: `unexpected ${action} function status ${response.value.status}: ${body}`, - notFoundIsInvalidInput: shouldUpdate, - }); - } else { - lastError = response.error; + const expectedStatus = shouldUpdate ? 200 : 201; + if (response.status === expectedStatus) { + // A success status with a malformed / unexpected JSON body is an + // API-response problem, not a transport failure — surface it via + // FunctionsApiStatusError so it classifies as api_status not network. + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + return yield* decodeDeployFunctionResponse(body).pipe( + Effect.mapError( + (error) => + new FunctionsApiStatusError({ + status: response.status, + message: `failed to read function response: ${error.message}`, + decode: true, + }), + ), + ); } - if (attempt < 3) { - yield* Effect.sleep(Duration.millis(500 * 2 ** attempt)); + const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + if (!shouldUpdate && body.includes("Duplicated function slug")) { + yield* Ref.set(shouldUpdateRef, true); } - } + return yield* new FunctionsApiStatusError({ + status: response.status, + message: `unexpected ${action} function status ${response.status}: ${body}`, + notFoundIsInvalidInput: yield* Ref.get(shouldUpdateRef), + }); + }); - return yield* Effect.fail( - lastError ?? new FunctionDeployError({ message: "failed to upsert function" }), + return yield* upsert.pipe( + Effect.retry({ + schedule: Schedule.exponential(Duration.millis(500)), + times: 3, + while: (error) => !(error instanceof FunctionsApiStatusError && error.decode === true), + }), ); }); @@ -2295,15 +2256,15 @@ const deployViaApi = Effect.fn("functions.deploy.viaApi")(function* ( ? new Map((yield* listRemoteFunctions(api, projectRef)).map((fn) => [fn.slug, fn])) : new Map(); - if (enabled.length === 1) { - const config = enabled[0]!; + const [singleConfig] = enabled; + if (enabled.length === 1 && singleConfig !== undefined) { yield* uploadFunctionSource( api, projectRef, sourceRoot, projectRoot, - config, - createSourceMetadata(path, projectRoot, config, remoteBySlug.get(config.slug)), + singleConfig, + createSourceMetadata(path, projectRoot, singleConfig, remoteBySlug.get(singleConfig.slug)), false, ); return; diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index 9d581fa23e..cb5e00f3cb 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -28,6 +28,7 @@ import { import { styleText } from "node:util"; import { Clock, + Data, FileSystem, Path, Deferred, @@ -622,16 +623,17 @@ const resolveLocalAuthArtifacts = Effect.fn("functions.serve.resolveLocalAuthArt ); } + const [firstSigningKey] = signingKeys; const anonKey = auth.anon_key === undefined || auth.anon_key.length === 0 - ? signingKeys.length > 0 - ? generateAsymmetricJwt(signingKeys[0]!, "anon", yield* Clock.currentTimeMillis) + ? firstSigningKey !== undefined + ? generateAsymmetricJwt(firstSigningKey, "anon", yield* Clock.currentTimeMillis) : 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", yield* Clock.currentTimeMillis) + ? firstSigningKey !== undefined + ? generateAsymmetricJwt(firstSigningKey, "service_role", yield* Clock.currentTimeMillis) : generateSymmetricJwt(jwtSecret, "service_role") : auth.service_role_key; const shouldUseJwtSecretFallback = signingKeysPath.length === 0; @@ -1324,10 +1326,25 @@ function consumeCompleteLines(buffer: string): { } /** Why `streamContainerLogs` stopped following the container without failing. */ -type ContainerLogsEndReason = - | { readonly _tag: "containerExited" } - | { readonly _tag: "supervisorTerminated"; readonly exitCode: number } - | { readonly _tag: "containerGone" }; +type ContainerLogsEndReason = Data.TaggedEnum<{ + ContainerExited: {}; + SupervisorTerminated: { readonly exitCode: number }; + ContainerGone: {}; +}>; +const ContainerLogsEndReason = Data.taggedEnum(); + +type ServeSessionOutcome = Data.TaggedEnum<{ + Shutdown: {}; + Restart: {}; + Exited: { readonly reason: ContainerLogsEndReason }; +}>; +const ServeSessionOutcome = Data.taggedEnum(); + +type ServeStartOutcome = Data.TaggedEnum<{ + Shutdown: {}; + Started: { readonly started: StartedRuntime }; +}>; +const ServeStartOutcome = Data.taggedEnum(); type Spawner = ChildProcessSpawner.ChildProcessSpawner["Service"]; @@ -1427,7 +1444,7 @@ const streamContainerLogs = Effect.fn("functions.serve.streamContainerLogs")(fun const inspected = yield* inspectContainerState(spawner, containerId).pipe(Effect.result); if (Result.isFailure(inspected)) { if (isContainerNotFoundMessage(inspected.failure.message)) { - return { _tag: "containerGone" } satisfies ContainerLogsEndReason; + return ContainerLogsEndReason.ContainerGone(); } return yield* inspected.failure; } @@ -1437,13 +1454,10 @@ const streamContainerLogs = Effect.fn("functions.serve.streamContainerLogs")(fun continue; } if (externalTerminationExitCodes.has(state.exitCode)) { - return { - _tag: "supervisorTerminated", - exitCode: state.exitCode, - } satisfies ContainerLogsEndReason; + return ContainerLogsEndReason.SupervisorTerminated({ exitCode: state.exitCode }); } if (state.exitCode === 0) { - return { _tag: "containerExited" } satisfies ContainerLogsEndReason; + return ContainerLogsEndReason.ContainerExited(); } // `supabase stop` force-kills a runtime that ignored SIGTERM and prunes it immediately // after, so a second inspect separates that teardown from a kill that leaves the @@ -1452,7 +1466,7 @@ const streamContainerLogs = Effect.fn("functions.serve.streamContainerLogs")(fun yield* Effect.sleep(retryDelay); const recheck = yield* inspectContainerState(spawner, containerId).pipe(Effect.result); if (Result.isFailure(recheck) && isContainerNotFoundMessage(recheck.failure.message)) { - return { _tag: "containerGone" } satisfies ContainerLogsEndReason; + return ContainerLogsEndReason.ContainerGone(); } } return yield* new EdgeRuntimeContainerCrashedError({ @@ -1471,7 +1485,7 @@ const streamContainerLogs = Effect.fn("functions.serve.streamContainerLogs")(fun const inspected = yield* inspectContainerState(spawner, containerId).pipe(Effect.result); if (Result.isFailure(inspected)) { if (isContainerNotFoundMessage(inspected.failure.message)) { - return { _tag: "containerGone" } satisfies ContainerLogsEndReason; + return ContainerLogsEndReason.ContainerGone(); } return yield* new DockerLogsStreamError({ message: @@ -1600,12 +1614,11 @@ const writeStoppedServingMessage = Effect.fnUntraced(function* () { const writeContainerEndedMessage = Effect.fnUntraced(function* (reason: ContainerLogsEndReason) { const path = yield* Path.Path; const output = yield* Output; - const prefix = - reason._tag === "containerExited" - ? "Edge Runtime exited (code 0)." - : reason._tag === "supervisorTerminated" - ? `Edge Runtime container stopped (exit ${reason.exitCode}).` - : "Edge Runtime container is no longer available."; + const prefix = ContainerLogsEndReason.$match(reason, { + ContainerExited: () => "Edge Runtime exited (code 0).", + SupervisorTerminated: ({ exitCode }) => `Edge Runtime container stopped (exit ${exitCode}).`, + ContainerGone: () => "Edge Runtime container is no longer available.", + }); yield* output.raw( `${prefix} Stopped serving ${styleText("bold", path.join("supabase", "functions"))}\n`, "stdout", @@ -2124,9 +2137,9 @@ const withShutdownGrace = ( ): Effect.Effect => self.pipe( Effect.catch((error) => - Effect.raceFirst( - Deferred.await(shutdownRequested).pipe(Effect.as(onShutdown())), - Effect.sleep(gracePeriod).pipe(Effect.andThen(Effect.fail(error))), + Deferred.await(shutdownRequested).pipe( + Effect.as(onShutdown()), + Effect.timeoutOrElse({ duration: gracePeriod, orElse: () => Effect.fail(error) }), ), ), ); @@ -2161,54 +2174,57 @@ export const serveFunctions = Effect.fn("functions.serve")(function* ( for (;;) { const startOutcome = yield* withShutdownGrace( Effect.raceFirst( - Deferred.await(shutdownRequested).pipe(Effect.as("shutdown" as const)), + Deferred.await(shutdownRequested).pipe(Effect.as(ServeStartOutcome.Shutdown())), startEdgeRuntime({ flags, dependencies, debug: dependencies.debug, networkId: dependencies.networkId, inspectMode, - }).pipe(Effect.map((started) => ({ _tag: "started" as const, started }))), - ), - shutdownRequested, - gracePeriod, - () => "shutdown" as const, - ); - - if (startOutcome === "shutdown") { - yield* writeStoppedServingMessage(); - return; - } - - const started = startOutcome.started; - - const outcome = yield* withShutdownGrace( - Effect.raceFirst( - Effect.raceFirst( - Deferred.await(shutdownRequested).pipe(Effect.as({ _tag: "shutdown" as const })), - waitForRestartSignal(started.watchSpecs).pipe(Effect.as({ _tag: "restart" as const })), - ), - streamContainerLogs(started.containerId, retryDelay).pipe( - Effect.map((reason) => ({ _tag: "exited" as const, reason })), - ), + }).pipe(Effect.map((started) => ServeStartOutcome.Started({ started }))), ), - // raceFirst above already interrupted the restart listener, so only a shutdown - // signal — not a restart — can still downgrade this failure here. shutdownRequested, gracePeriod, - () => ({ _tag: "shutdown" as const }), - ).pipe( - Effect.ensuring( - bestEffortRemoveContainer(started.containerId).pipe(Effect.ensuring(started.cleanup)), - ), + () => ServeStartOutcome.Shutdown(), ); - if (outcome._tag === "shutdown") { - yield* writeStoppedServingMessage(); - return; - } - if (outcome._tag === "exited") { - yield* writeContainerEndedMessage(outcome.reason); + const keepServing = yield* ServeStartOutcome.$match(startOutcome, { + Shutdown: () => writeStoppedServingMessage().pipe(Effect.as(false)), + Started: ({ started }) => + Effect.gen(function* () { + const outcome = yield* withShutdownGrace( + Effect.raceFirst( + Effect.raceFirst( + Deferred.await(shutdownRequested).pipe(Effect.as(ServeSessionOutcome.Shutdown())), + waitForRestartSignal(started.watchSpecs).pipe( + Effect.as(ServeSessionOutcome.Restart()), + ), + ), + streamContainerLogs(started.containerId, retryDelay).pipe( + Effect.map((reason) => ServeSessionOutcome.Exited({ reason })), + ), + ), + // raceFirst above already interrupted the restart listener, so only a shutdown + // signal — not a restart — can still downgrade this failure here. + shutdownRequested, + gracePeriod, + () => ServeSessionOutcome.Shutdown(), + ).pipe( + Effect.ensuring( + bestEffortRemoveContainer(started.containerId).pipe( + Effect.ensuring(started.cleanup), + ), + ), + ); + + return yield* ServeSessionOutcome.$match(outcome, { + Restart: () => Effect.succeed(true), + Shutdown: () => writeStoppedServingMessage().pipe(Effect.as(false)), + Exited: ({ reason }) => writeContainerEndedMessage(reason).pipe(Effect.as(false)), + }); + }), + }); + if (!keepServing) { return; } } 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 511e107fe6..17603b9dae 100644 --- a/apps/cli/src/shared/git/git-branch.unit.test.ts +++ b/apps/cli/src/shared/git/git-branch.unit.test.ts @@ -31,7 +31,7 @@ const writeHead = Effect.fnUntraced(function* (root: string, contents: string) { }); describe("detectGitBranch", () => { - it.live("returns $GITHUB_HEAD_REF when set", () => + it.effect("returns $GITHUB_HEAD_REF when set", () => Effect.gen(function* () { const root = yield* makeTempDir("git-branch-ci-"); const got = yield* detectGitBranch().pipe( @@ -41,7 +41,7 @@ describe("detectGitBranch", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("ignores an empty $GITHUB_HEAD_REF and falls back to .git/HEAD", () => + it.effect("ignores an empty $GITHUB_HEAD_REF and falls back to .git/HEAD", () => Effect.gen(function* () { const root = yield* makeTempDir("git-branch-empty-ref-"); yield* writeHead(root, "ref: refs/heads/feature-x\n"); @@ -52,7 +52,7 @@ describe("detectGitBranch", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("parses ref: refs/heads/ from .git/HEAD in CWD", () => + it.effect("parses ref: refs/heads/ from .git/HEAD in CWD", () => Effect.gen(function* () { const root = yield* makeTempDir("git-branch-"); yield* writeHead(root, "ref: refs/heads/feature-x\n"); @@ -61,7 +61,7 @@ describe("detectGitBranch", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("walks up parent directories until .git/HEAD is found", () => + it.effect("walks up parent directories until .git/HEAD is found", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -74,7 +74,7 @@ describe("detectGitBranch", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("returns none when no .git/HEAD is ever found", () => + it.effect("returns none when no .git/HEAD is ever found", () => Effect.gen(function* () { const root = yield* makeTempDir("git-branch-none-"); const got = yield* detectGitBranch().pipe(Effect.provide(withCwd(root))); @@ -82,7 +82,7 @@ describe("detectGitBranch", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("returns none when .git/HEAD points at a detached commit (no ref: line)", () => + it.effect("returns none when .git/HEAD points at a detached commit (no ref: line)", () => Effect.gen(function* () { const root = yield* makeTempDir("git-branch-detached-"); yield* writeHead(root, "deadbeef\n"); @@ -91,7 +91,7 @@ describe("detectGitBranch", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("walks from an explicit startDir instead of the runtime CWD", () => + it.effect("walks from an explicit startDir instead of the runtime CWD", () => 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 ` run @@ -104,7 +104,7 @@ describe("detectGitBranch", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("resolves a worktree's `.git` gitlink file to its own HEAD", () => + it.effect("resolves a worktree's `.git` gitlink file to its own HEAD", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -122,7 +122,7 @@ describe("detectGitBranch", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("resolves a relative gitdir in a `.git` gitlink against its directory", () => + it.effect("resolves a relative gitdir in a `.git` gitlink against its directory", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -136,7 +136,7 @@ describe("detectGitBranch", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live("stops at the nearest .git instead of a detached HEAD's parent checkout", () => + it.effect("stops at the nearest .git instead of a detached HEAD's parent checkout", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; 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 b89df561fd..e1846fec3e 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 @@ -39,7 +39,7 @@ const fileMode = Effect.fnUntraced(function* (pathname: string) { }); describe("initProject file modes (Go parity: 0755 dirs, 0644 files)", () => { - it.live("pins the supabase dir and config.toml to Go's exact modes", () => + it.effect("pins the supabase dir and config.toml to Go's exact modes", () => Effect.gen(function* () { const path = yield* Path.Path; const cwd = yield* makeTempProjectDir; @@ -53,7 +53,7 @@ describe("initProject file modes (Go parity: 0755 dirs, 0644 files)", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.live( + it.effect( "pins a freshly created supabase/.gitignore to Go's exact file mode inside a git repo", () => Effect.gen(function* () { diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 5440a67521..8d24647833 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -392,12 +392,11 @@ export const textOutputLayer = Layer.effect( opts?: { validate?: (v: string) => string | undefined; defaultValue?: string }, ) => Effect.gen(function* () { + const validate = opts?.validate; const value = yield* Effect.promise(() => text({ message, - validate: opts?.validate - ? (v: string | undefined) => opts.validate!(v ?? "") - : undefined, + validate: validate ? (v: string | undefined) => validate(v ?? "") : undefined, defaultValue: opts?.defaultValue, }), ); diff --git a/apps/cli/src/shared/runtime/process-control.layer.ts b/apps/cli/src/shared/runtime/process-control.layer.ts index 631882ea0c..7d5220a327 100644 --- a/apps/cli/src/shared/runtime/process-control.layer.ts +++ b/apps/cli/src/shared/runtime/process-control.layer.ts @@ -17,23 +17,21 @@ export const processControlLayer = Layer.sync(ProcessControl, () => awaitSignal: (signals = defaultSignals) => Effect.callback((resume) => { const cleanup = () => { - for (const signal of signals) { - process.off(signal, listeners[signal]); + for (const { signal, listener } of listeners) { + process.off(signal, listener); } }; - const listeners = Object.fromEntries( - signals.map((signal) => [ - signal, - () => { - cleanup(); - resume(Effect.succeed(signal)); - }, - ]), - ) as Record void>; + const listeners = signals.map((signal) => ({ + signal, + listener: () => { + cleanup(); + resume(Effect.succeed(signal)); + }, + })); - for (const signal of signals) { - process.once(signal, listeners[signal]); + for (const { signal, listener } of listeners) { + process.once(signal, listener); } return Effect.sync(cleanup); diff --git a/apps/cli/src/shared/runtime/stdin.integration.test.ts b/apps/cli/src/shared/runtime/stdin.integration.test.ts index 6278d7a621..cffaa82a83 100644 --- a/apps/cli/src/shared/runtime/stdin.integration.test.ts +++ b/apps/cli/src/shared/runtime/stdin.integration.test.ts @@ -18,7 +18,7 @@ const withStdin = (stdin: Stream.Stream, stdinIsTty = stdinLayerFrom(stdin).pipe(Layer.provide(mockTty({ stdinIsTty, stdoutIsTty: false }))); describe("stdinLayer", () => { - it.live("dispenses successive lines across calls, buffering multi-line chunks", () => { + it.effect("dispenses successive lines across calls, buffering multi-line chunks", () => { // Two chunks; the second carries two lines to prove buffering across calls. const layer = withStdin(Stream.fromIterable([enc("a\n"), enc("b\nc\n")])); return Effect.gen(function* () { @@ -30,7 +30,7 @@ describe("stdinLayer", () => { }).pipe(Effect.provide(layer)); }); - it.live("accepts CRLF and bare CR line endings and a final line without one", () => { + it.effect("accepts CRLF and bare CR line endings and a final line without one", () => { // Answers piped from Windows tooling (`\r\n`) or an old Mac convention (`\r`), plus // `printf y` with no trailing newline, all read as whole lines before EOF. const layer = withStdin(Stream.fromIterable([enc("a\r\nb\rc")])); @@ -43,7 +43,7 @@ describe("stdinLayer", () => { }).pipe(Effect.provide(layer)); }); - it.live("preserves interior blank lines so answers stay aligned", () => { + it.effect("preserves interior blank lines so answers stay aligned", () => { // A caller piping "\ny\n" must see the blank line first, then "y" — not "y" first. const layer = withStdin(Stream.fromIterable([enc("\ny\n")])); return Effect.gen(function* () { @@ -53,15 +53,17 @@ describe("stdinLayer", () => { }).pipe(Effect.provide(layer)); }); - it.live("times out to None when no line arrives within the window", () => { + it.effect("times out to None when no line arrives within the window", () => { const layer = withStdin(Stream.never); return Effect.gen(function* () { const stdin = yield* Stdin; - expect(yield* stdin.readLine(100)).toStrictEqual(Option.none()); + const reading = yield* Effect.forkChild(stdin.readLine(100)); + yield* TestClock.adjust(Duration.millis(100)); + expect(yield* Fiber.join(reading)).toStrictEqual(Option.none()); }).pipe(Effect.provide(layer)); }); - it.live("waits for a non-blocking pipe that has nothing to read yet", () => + it.effect("waits for a non-blocking pipe that has nothing to read yet", () => Effect.gen(function* () { // A non-blocking fd 0 with nothing to read fails with `WouldBlock` (how // the layer reports `EAGAIN`) — "nothing yet", not EOF. @@ -76,7 +78,10 @@ describe("stdinLayer", () => { ); yield* Effect.gen(function* () { const stdin = yield* Stdin; - expect(yield* stdin.readLine(10_000)).toStrictEqual(Option.some("y")); + const reading = yield* Effect.forkChild(stdin.readLine(10_000)); + yield* TestClock.adjust(Duration.millis(10)); + yield* TestClock.adjust(Duration.millis(10)); + expect(yield* Fiber.join(reading)).toStrictEqual(Option.some("y")); expect(attempts).toBe(3); }).pipe(Effect.provide(layer)); }), @@ -156,7 +161,7 @@ describe("stdinLayer", () => { }), ); - it.live("lets one prompt at a time pull from the pipe", () => + it.effect("lets one prompt at a time pull from the pipe", () => Effect.gen(function* () { // Two prompts wait at once; the second must get the value held back from // the first prompt's chunk, not pull (and skip) a chunk of its own. @@ -180,7 +185,7 @@ describe("stdinLayer", () => { }), ); - it.live("reads a pipe only while a prompt is waiting", () => + it.effect("reads a pipe only while a prompt is waiting", () => Effect.gen(function* () { // Nothing is pulled before the first prompt or between prompts; the // producer is counted per chunk to prove that. @@ -201,7 +206,7 @@ describe("stdinLayer", () => { }), ); - it.live("answers every prompt in order when a producer floods the pipe", () => { + it.effect("answers every prompt in order when a producer floods the pipe", () => { // 10,000 lines total well over the 64 KiB pending-line bound, so that // bound must reset at each line break, not accumulate across lines. const flood = Array.from({ length: 10_000 }, (_, index) => enc(`line-${index}\n`)); @@ -214,7 +219,7 @@ describe("stdinLayer", () => { }).pipe(Effect.provide(layer)); }); - it.live("gives up on a line that never ends instead of buffering it", () => + it.effect("gives up on a line that never ends instead of buffering it", () => Effect.gen(function* () { // A newline-less producer (`yes | tr -d '\n'`), counted per 16 KiB chunk: // the reader stops pulling once the pending line exceeds its 64 KiB @@ -234,7 +239,7 @@ describe("stdinLayer", () => { }), ); - it.live("answers the lines ahead of a runaway tail that shares their chunk", () => { + it.effect("answers the lines ahead of a runaway tail that shares their chunk", () => { // The answer and the start of an unterminated blob can land in one pull; // the answer still delivers, then the blob trips the bound so the "n" // behind it is never read. @@ -250,7 +255,7 @@ describe("stdinLayer", () => { }).pipe(Effect.provide(layer)); }); - it.live("dispenses successive lines on a TTY, which is read only when prompted", () => { + it.effect("dispenses successive lines on a TTY, which is read only when prompted", () => { const layer = withStdin(Stream.fromIterable([enc("a\n"), enc("b\nc\n")]), true); return Effect.gen(function* () { const stdin = yield* Stdin; diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index b12ad117e6..297c675921 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -509,6 +509,7 @@ export function formatServicesWarning(message: string, textMode: boolean): strin export function fetchLinkedServiceVersions(input: ServiceFetchConfig) { return Effect.gen(function* () { + const noLinkedVersions: Partial> = {}; const exit = yield* Effect.gen(function* () { // Malformed refs are rejected before reaching the management API path param or the // tenant gateway hostname; the test-only override bypasses this check. @@ -516,7 +517,7 @@ export function fetchLinkedServiceVersions(input: ServiceFetchConfig) { input.tenantBaseUrlOverride === undefined && !PROJECT_REF_PATTERN.test(input.projectRef) ) { - return {} as Partial>; + return noLinkedVersions; } const client = yield* makeConfiguredApiClient(input); @@ -526,7 +527,7 @@ export function fetchLinkedServiceVersions(input: ServiceFetchConfig) { .getProjectApiKeys({ ref: input.projectRef, reveal: true }) .pipe(Effect.exit); if (!Exit.isSuccess(apiKeysExit) || !hasProjectAccessKey(apiKeysExit.value)) { - return {} as Partial>; + return noLinkedVersions; } let versions: Partial> = {}; @@ -577,7 +578,7 @@ export function fetchLinkedServiceVersions(input: ServiceFetchConfig) { return versions; }).pipe(Effect.exit); - return Exit.isSuccess(exit) ? exit.value : ({} as Partial>); + return Exit.isSuccess(exit) ? exit.value : noLinkedVersions; }).pipe( Effect.withSpan("Services.fetchLinkedVersions", { attributes: { "project.ref": input.projectRef }, 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 3c3d7cef40..6dac6482f3 100644 --- a/apps/cli/src/shared/services/services.shared.unit.test.ts +++ b/apps/cli/src/shared/services/services.shared.unit.test.ts @@ -643,9 +643,11 @@ describe("services shared", () => { it.live("skips remote lookups for a malformed project ref", () => Effect.gen(function* () { + const requests: Array = []; const server = yield* serve({ port: 0, - fetch() { + fetch(request) { + requests.push(request.url); throw new Error("no request should be made for a malformed project ref"); }, }); @@ -659,6 +661,7 @@ describe("services shared", () => { }); expect(result).toEqual({}); + expect(requests).toEqual([]); }), ); 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 8a53c9c779..a3b319605d 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 @@ -5,7 +5,7 @@ import { aiToolLayer } from "./ai-tool.layer.ts"; import { AiTool } from "./ai-tool.service.ts"; describe("aiToolLayer", () => { - it.live("detects Codex environments via @vercel/detect-agent", () => + it.effect("detects Codex environments via @vercel/detect-agent", () => Effect.gen(function* () { const aiTool = yield* AiTool; expect(aiTool.name).toEqual(Option.some("codex")); @@ -14,7 +14,7 @@ describe("aiToolLayer", () => { ), ); - it.live("normalizes known agent names for analytics properties", () => + it.effect("normalizes known agent names for analytics properties", () => Effect.gen(function* () { const aiTool = yield* AiTool; expect(aiTool.name).toEqual(Option.some("github_copilot")); @@ -25,7 +25,7 @@ describe("aiToolLayer", () => { ), ); - it.live("returns none when no supported agent is detected", () => + it.effect("returns none when no supported agent is detected", () => Effect.gen(function* () { const aiTool = yield* AiTool; expect(aiTool.name).toEqual(Option.none()); 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 f51280a769..feddbd7bde 100644 --- a/apps/cli/src/shared/telemetry/analytics-context.unit.test.ts +++ b/apps/cli/src/shared/telemetry/analytics-context.unit.test.ts @@ -3,7 +3,7 @@ import { Effect, Fiber } from "effect"; import { CurrentAnalyticsContext, withAnalyticsContext } from "./analytics-context.ts"; describe("withAnalyticsContext", () => { - it.live("merges context lexically and restores the previous value afterward", () => + it.effect("merges context lexically and restores the previous value afterward", () => Effect.gen(function* () { const before = yield* CurrentAnalyticsContext; expect(before).toEqual({}); @@ -40,7 +40,7 @@ describe("withAnalyticsContext", () => { }), ); - it.live("is inherited by child fibers", () => + it.effect("is inherited by child fibers", () => Effect.gen(function* () { const child = yield* Effect.gen(function* () { const fiber = yield* Effect.forkChild(CurrentAnalyticsContext); diff --git a/apps/cli/src/shared/telemetry/consent.integration.test.ts b/apps/cli/src/shared/telemetry/consent.integration.test.ts index 38e39b2b12..436c04aacc 100644 --- a/apps/cli/src/shared/telemetry/consent.integration.test.ts +++ b/apps/cli/src/shared/telemetry/consent.integration.test.ts @@ -9,6 +9,7 @@ import { Fiber, Path, PlatformError, + Predicate, Ref, Result, Schema, @@ -160,12 +161,12 @@ describe("writeTelemetryConfig", () => { expect(platformError.reason.description).toContain("EACCES"); expect(platformError.reason.description).not.toContain("FileSystem.rename"); } - expect( + const wrapsPermissionDenied = platformError instanceof PlatformError.PlatformError && - platformError.reason._tag === "PermissionDenied" && - platformError.cause instanceof PlatformError.PlatformError && - platformError.cause.cause === originalCause, - ).toBe(true); + Predicate.isTagged(platformError.reason, "PermissionDenied") && + platformError.cause instanceof PlatformError.PlatformError && + platformError.cause.cause === originalCause; + expect(wrapsPermissionDenied).toBe(true); } } expect(yield* Ref.get(calls)).toBe(13); diff --git a/apps/cli/src/shared/telemetry/consent.ts b/apps/cli/src/shared/telemetry/consent.ts index 870d58d70a..b5f98360f7 100644 --- a/apps/cli/src/shared/telemetry/consent.ts +++ b/apps/cli/src/shared/telemetry/consent.ts @@ -35,7 +35,7 @@ const decodeCurrentTelemetryConfigFile = Schema.decodeUnknownEffect(TelemetryCon const decodeLegacyTelemetryConfigFile = Schema.decodeUnknownEffect( Schema.fromJsonString(LegacyTelemetryConfigSchema), ); -const encodeTelemetryConfig = Schema.encodeUnknownSync(TelemetryConfigSchema); +const encodeTelemetryConfig = Schema.encodeEffect(TelemetryConfigSchema); function encodePrettyJson(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n`; @@ -114,9 +114,8 @@ export const writeTelemetryConfig = Effect.fnUntraced(function* ( Effect.succeed(tmpPath), (temporary) => Effect.gen(function* () { - yield* fs.writeFileString(temporary, encodePrettyJson(encodeTelemetryConfig(config)), { - mode: 0o600, - }); + const encoded = yield* encodeTelemetryConfig(config); + yield* fs.writeFileString(temporary, encodePrettyJson(encoded), { mode: 0o600 }); yield* fs.rename(temporary, configPath).pipe( Effect.retry({ schedule: retrySchedule, 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 80b9c8549e..aae21cd702 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 @@ -7,7 +7,7 @@ import { pathToFileURL } from "node:url"; const encodeSpecifier = Schema.encodeEffect(Schema.fromJsonString(Schema.String)); describe("release-minified error fingerprints", () => { - it.live("keeps a declared tagged error's source identifier", () => + it.effect("keeps a declared tagged error's source identifier", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts b/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts index 6b9632666c..cf6761a714 100644 --- a/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts @@ -187,7 +187,7 @@ describe("error tag stability", () => { ), ); - it.live("keeps every Data.TaggedError tag literal identical to the committed snapshot", () => + it.effect("keeps every Data.TaggedError tag literal identical to the committed snapshot", () => Effect.gen(function* () { const currentTagSet = new Set(productionDeclarations.map((declaration) => declaration.tag)); diff --git a/apps/cli/src/shared/telemetry/exporters/debug-console.ts b/apps/cli/src/shared/telemetry/exporters/debug-console.ts index 6f6110848a..83af4c0fcf 100644 --- a/apps/cli/src/shared/telemetry/exporters/debug-console.ts +++ b/apps/cli/src/shared/telemetry/exporters/debug-console.ts @@ -1,4 +1,4 @@ -import { Cause, DateTime, Effect, Exit, Option, Schema } from "effect"; +import { Cause, DateTime, Effect, Exit, Option, Predicate, Schema } from "effect"; import type { PlatformError, Tracer } from "effect"; import type { TraceSanitizer } from "../trace-sanitize.ts"; @@ -30,7 +30,11 @@ const isSameSpan = (span: Tracer.AnySpan, other: Option.Option): function spanDepth(span: Tracer.Span, root: Option.Option): number { let depth = 0; let parent = span.parent; - while (Option.isSome(parent) && parent.value._tag === "Span" && !isSameSpan(parent.value, root)) { + while ( + Option.isSome(parent) && + Predicate.isTagged(parent.value, "Span") && + !isSameSpan(parent.value, root) + ) { depth += 1; parent = parent.value.parent; } @@ -39,7 +43,7 @@ function spanDepth(span: Tracer.Span, root: Option.Option): numb function spanFailed(span: Tracer.Span): boolean { return ( - span.status._tag === "Ended" && + Predicate.isTagged(span.status, "Ended") && Exit.isFailure(span.status.exit) && !Cause.hasInterruptsOnly(span.status.exit.cause) ); @@ -51,7 +55,7 @@ export const formatSpanForDebugConsole = Effect.fnUntraced(function* ( root: Option.Option = Option.none(), ) { const status = span.status; - if (status._tag !== "Ended") return Option.none(); + if (!Predicate.isTagged(status, "Ended")) return Option.none(); const depth = spanDepth(span, root); const failed = spanFailed(span); if (depth > MAX_PRINTED_DEPTH && !failed) return Option.none(); diff --git a/apps/cli/src/shared/telemetry/otlp-trace-sink.ts b/apps/cli/src/shared/telemetry/otlp-trace-sink.ts index bc391df5bb..28faf6163e 100644 --- a/apps/cli/src/shared/telemetry/otlp-trace-sink.ts +++ b/apps/cli/src/shared/telemetry/otlp-trace-sink.ts @@ -5,6 +5,7 @@ import { Layer, Option, PlatformError, + Predicate, Redacted, Semaphore, } from "effect"; @@ -81,10 +82,9 @@ export const fileTransportLayer = (path: string) => // A file we did not create may belong to someone else; owner-only mode is then best-effort. yield* Effect.ignore(fs.chmod(path, 0o600)); return HttpClient.make((request) => { - const write = - request.body._tag === "Uint8Array" - ? fs.writeFile(path, appendNewline(request.body.body), { flag: "a", mode: 0o600 }) - : Effect.void; + const write = Predicate.isTagged(request.body, "Uint8Array") + ? fs.writeFile(path, appendNewline(request.body.body), { flag: "a", mode: 0o600 }) + : Effect.void; return lock .withPermits(1)(write) .pipe( 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 8e196a420c..de9df30ed6 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.unit.test.ts @@ -33,7 +33,7 @@ const respondingClient = ( ); describe("makePosthogFetch", () => { - it.live("sends the SDK request as given and passes successful responses through", () => { + it.effect("sends the SDK request as given and passes successful responses through", () => { const requests: Array = []; return Effect.gen(function* () { const posthogFetch = makePosthogFetch(yield* Effect.context()); @@ -53,7 +53,7 @@ describe("makePosthogFetch", () => { ); }); - it.live("sends nothing when the signal is already aborted", () => { + it.effect("sends nothing when the signal is already aborted", () => { const requests: Array = []; return Effect.gen(function* () { const posthogFetch = makePosthogFetch(yield* Effect.context()); @@ -108,7 +108,7 @@ describe("makePosthogFetch", () => { ); }); - it.live("reads a null body the way native fetch does", () => + it.effect("reads a null body the way native fetch does", () => Effect.gen(function* () { const posthogFetch = makePosthogFetch(yield* Effect.context()); const send = () => Effect.promise(() => posthogFetch(BATCH_URL, BATCH_OPTIONS)); @@ -127,7 +127,7 @@ describe("makePosthogFetch", () => { }).pipe(Effect.provide(respondingClient(() => new Response(null, { status: 204 })))), ); - it.live("reports success when the network is unreachable", () => + it.effect("reports success when the network is unreachable", () => Effect.gen(function* () { const posthogFetch = makePosthogFetch(yield* Effect.context()); const response = yield* Effect.promise(() => posthogFetch(BATCH_URL, BATCH_OPTIONS)); @@ -154,7 +154,7 @@ describe("makePosthogFetch", () => { ), ); - it.live("reports success on error responses so the SDK never retries or logs", () => + it.effect("reports success on error responses so the SDK never retries or logs", () => Effect.gen(function* () { const posthogFetch = makePosthogFetch(yield* Effect.context()); const response = yield* Effect.promise(() => posthogFetch(BATCH_URL, BATCH_OPTIONS)); 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 b5dfd09592..f7a0679e75 100644 --- a/apps/cli/src/shared/telemetry/posthog-config.unit.test.ts +++ b/apps/cli/src/shared/telemetry/posthog-config.unit.test.ts @@ -4,7 +4,7 @@ import { processEnvLayer } from "../../../tests/helpers/mocks.ts"; import { resolvePosthogConfig } from "./posthog-config.ts"; describe("resolvePosthogConfig", () => { - it.live("uses no key when nothing is injected or overridden", () => + it.effect("uses no key when nothing is injected or overridden", () => Effect.gen(function* () { const config = yield* resolvePosthogConfig( ConfigProvider.fromEnvRecord({}, { preserveEmptyStrings: true }), @@ -15,7 +15,7 @@ describe("resolvePosthogConfig", () => { }).pipe(Effect.provide(processEnvLayer())), ); - it.live("uses the build-injected key and host by default", () => + it.effect("uses the build-injected key and host by default", () => Effect.gen(function* () { const config = yield* resolvePosthogConfig( ConfigProvider.fromEnvRecord({}, { preserveEmptyStrings: true }), @@ -33,7 +33,7 @@ describe("resolvePosthogConfig", () => { ), ); - it.live("prefers runtime overrides over build-injected values", () => + it.effect("prefers runtime overrides over build-injected values", () => Effect.gen(function* () { const config = yield* resolvePosthogConfig( ConfigProvider.fromEnvRecord( diff --git a/apps/cli/src/shared/telemetry/trace-export.layer.ts b/apps/cli/src/shared/telemetry/trace-export.layer.ts index d5436c6979..3144736c7d 100644 --- a/apps/cli/src/shared/telemetry/trace-export.layer.ts +++ b/apps/cli/src/shared/telemetry/trace-export.layer.ts @@ -8,6 +8,7 @@ import { Fiber, FileSystem, Layer, + Match, Option, Path, Queue, @@ -262,12 +263,14 @@ const sinkTracer = Effect.fnUntraced(function* (sink: TraceSink) { }; const make = (url: string, headers: Redacted.Redacted>>) => sinkModule.makeOtlpTracer({ url, headers, resource }); - const tracer = - sink._tag === "File" - ? make("file:///v1/traces", Redacted.make({})).pipe( - Effect.provide(sinkModule.fileTransportLayer(sink.path)), - ) - : make(sink.url, sink.headers).pipe(Effect.provide(sinkModule.collectorTransportLayer)); + const tracer = Match.valueTags(sink, { + File: (file) => + make("file:///v1/traces", Redacted.make({})).pipe( + Effect.provide(sinkModule.fileTransportLayer(file.path)), + ), + Otlp: (otlp) => + make(otlp.url, otlp.headers).pipe(Effect.provide(sinkModule.collectorTransportLayer)), + }); return observedTracer(yield* tracer); }); diff --git a/apps/cli/src/shared/telemetry/trace-export.unit.test.ts b/apps/cli/src/shared/telemetry/trace-export.unit.test.ts index feb5463b09..a9e3971517 100644 --- a/apps/cli/src/shared/telemetry/trace-export.unit.test.ts +++ b/apps/cli/src/shared/telemetry/trace-export.unit.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; -import { ConfigProvider, Effect, Layer, Option, Path, Redacted, Sink, Stdio } from "effect"; +import { + ConfigProvider, + Effect, + Layer, + Option, + Path, + Predicate, + Redacted, + Sink, + Stdio, +} from "effect"; import { BunServices } from "@effect/platform-bun"; import { mockRuntimeInfo } from "../../../tests/helpers/mocks.ts"; import { CliSettings } from "../config/cli-settings.service.ts"; @@ -72,7 +82,7 @@ describe("resolveTraceSettings", () => { url: "http://localhost:4318/v1/traces", }); const sink = Option.getOrThrow(settings.sink); - expect(sink._tag === "Otlp" && Redacted.value(sink.headers)).toEqual({ + expect(Predicate.isTagged(sink, "Otlp") && Redacted.value(sink.headers)).toEqual({ "x-api-key": "abc=", "x-tenant": "team", }); diff --git a/apps/cli/src/shared/telemetry/trace-sanitize.ts b/apps/cli/src/shared/telemetry/trace-sanitize.ts index e6447a28e0..399d995d8c 100644 --- a/apps/cli/src/shared/telemetry/trace-sanitize.ts +++ b/apps/cli/src/shared/telemetry/trace-sanitize.ts @@ -1,5 +1,6 @@ import { createHmac } from "node:crypto"; import { Cause, Crypto, Effect, Exit } from "effect"; +import { absurd } from "effect/Function"; import type { KeyValue, AnyValue } from "effect/observability/OtlpResource"; import type { ScopeSpan, TraceData } from "effect/observability/OtlpTracer"; @@ -149,9 +150,9 @@ type AttributeDecision = function decide(key: string, numericOrBoolean: boolean): AttributeDecision { if (key === "db.query.text") return { _tag: "Query" }; if (key === "url.query") return { _tag: "Drop" }; - const header = HEADER_ATTRIBUTE.exec(key); - if (header !== null) { - return ALLOWED_HEADERS.has(header[1]!.toLowerCase()) ? { _tag: "Keep" } : { _tag: "Drop" }; + const headerName = HEADER_ATTRIBUTE.exec(key)?.[1]; + if (headerName !== undefined) { + return ALLOWED_HEADERS.has(headerName.toLowerCase()) ? { _tag: "Keep" } : { _tag: "Drop" }; } if (!numericOrBoolean && DENIED_KEY.test(key)) return { _tag: "Drop" }; return { _tag: "Keep" }; @@ -184,12 +185,15 @@ function sanitizeAttributeEntries( case "Query": if (typeof value === "string") result.push(...queryAttributes(value, hashQuery)); break; - default: + case "Keep": result.push([ key, (typeof value === "string" ? sanitizeUrlAttribute(key, value) : undefined) ?? scrubUnknown(value), ]); + break; + default: + absurd(decision); } } return result; @@ -237,7 +241,7 @@ function sanitizeKeyValues( } break; } - default: { + case "Keep": { const text = attribute.value.stringValue; const url = typeof text === "string" ? sanitizeUrlAttribute(attribute.key, text) : undefined; @@ -246,7 +250,10 @@ function sanitizeKeyValues( value: url === undefined ? scrubAnyValue(attribute.value, hashQuery) : { stringValue: url }, }); + break; } + default: + absurd(decision); } } return result;