From 5a87723b5cd8b6ac283bd11ca944b2a74773707d Mon Sep 17 00:00:00 2001 From: 7ttp <117663341+7ttp@users.noreply.github.com> Date: Sat, 17 Oct 2026 21:47:54 +0530 Subject: [PATCH] refactor(api): cover the client sources with effect lint (CLI-2841) --- .oxlintrc.effect.json | 2 + apps/cli/src/command-internal/http-errors.ts | 2 +- apps/cli/src/commands/start/start.handler.ts | 2 +- .../api/src/bun-lifecycle.integration.test.ts | 39 +- packages/api/src/bun.ts | 13 +- .../src/config/api-config.layer.unit.test.ts | 36 +- packages/api/src/effect.unit.test.ts | 841 +++--- packages/api/src/entrypoints.unit.test.ts | 65 +- .../src/generated-contract-sync.unit.test.ts | 29 +- packages/api/src/internal/client.ts | 50 +- packages/api/src/internal/client.unit.test.ts | 2678 +++++++++-------- packages/api/src/internal/effect-client.ts | 4 +- packages/api/src/internal/promise-client.ts | 10 +- .../src/internal/promise-client.unit.test.ts | 187 +- .../src/node-lifecycle.integration.test.ts | 190 +- packages/api/src/node.ts | 13 +- 16 files changed, 2150 insertions(+), 2011 deletions(-) diff --git a/.oxlintrc.effect.json b/.oxlintrc.effect.json index ad8cb1feb6..4f1508de43 100644 --- a/.oxlintrc.effect.json +++ b/.oxlintrc.effect.json @@ -6,6 +6,7 @@ }, "ignorePatterns": [ "**", + "!packages/api/src/**", "!packages/config/**", "!packages/stack/**", "!apps/cli/src/**", @@ -16,6 +17,7 @@ "apps/cli/src/shared/feedback/database.types.ts", "apps/cli/src/command-internal/db-bootstrap/local-db-running.integration.test.ts", "apps/cli/src/command-internal/db-bootstrap/local-db-running.ts", + "packages/api/src/generated/contracts.ts", "!apps/cli/tests/helpers/branches-live.ts", "!apps/cli/tests/helpers/compute.ts", "!apps/cli/tests/helpers/migration-live.ts", diff --git a/apps/cli/src/command-internal/http-errors.ts b/apps/cli/src/command-internal/http-errors.ts index 168deb48d0..136945f5a8 100644 --- a/apps/cli/src/command-internal/http-errors.ts +++ b/apps/cli/src/command-internal/http-errors.ts @@ -157,7 +157,7 @@ export function mapHttpError(opts: { if (cause instanceof SupabaseApiInputError || cause instanceof HttpBody.HttpBodyError) { // Client-side validation/build failures: keep their identity, since // this generic mapper can't safely reclassify them as response errors. - return yield* Effect.fail(cause); + return yield* cause; } if (HttpClientError.isHttpClientError(cause)) { if (RESPONSE_ERROR_TAGS.has(cause.reason._tag) && cause.response !== undefined) { diff --git a/apps/cli/src/commands/start/start.handler.ts b/apps/cli/src/commands/start/start.handler.ts index c800f3bf1d..cf6764dd03 100644 --- a/apps/cli/src/commands/start/start.handler.ts +++ b/apps/cli/src/commands/start/start.handler.ts @@ -1851,7 +1851,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { if (Result.isFailure(seedResult)) { // No manual `rollbackStart` here — the outer // `Effect.onError` below rolls back on this failure too. - return yield* Effect.fail(seedResult.failure); + return yield* seedResult.failure; } } } diff --git a/packages/api/src/bun-lifecycle.integration.test.ts b/packages/api/src/bun-lifecycle.integration.test.ts index 3c381d27d2..91c84c1577 100644 --- a/packages/api/src/bun-lifecycle.integration.test.ts +++ b/packages/api/src/bun-lifecycle.integration.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "vitest"; +import { Effect } from "effect"; import { createApiClient } from "./bun.ts"; describe("Bun client lifecycle", () => { - test("serves requests through the Bun entrypoint and rejects operations after disposal", async () => { + test("serves requests through the Bun entrypoint and rejects operations after disposal", () => { const paths: string[] = []; const server = Bun.serve({ hostname: "127.0.0.1", @@ -12,19 +13,27 @@ describe("Bun client lifecycle", () => { return Response.json([]); }, }); - const client = await createApiClient({ - baseUrl: server.url.toString(), - accessToken: "test-token", - }); - try { - await expect(client.v1.listAllProjects()).resolves.toEqual([]); - expect(paths).toEqual(["/v1/projects"]); - await client.dispose(); - await expect(client.v1.listAllProjects()).rejects.toThrow(); - expect(paths).toEqual(["/v1/projects"]); - } finally { - await client.dispose(); - await server.stop(true); - } + return Effect.runPromise( + Effect.acquireUseRelease( + Effect.promise(() => + createApiClient({ + baseUrl: server.url.toString(), + accessToken: "test-token", + }), + ), + (client) => + Effect.gen(function* () { + yield* Effect.promise(() => expect(client.v1.listAllProjects()).resolves.toEqual([])); + expect(paths).toEqual(["/v1/projects"]); + yield* Effect.promise(() => client.dispose()); + yield* Effect.promise(() => expect(client.v1.listAllProjects()).rejects.toThrow()); + expect(paths).toEqual(["/v1/projects"]); + }), + (client) => + Effect.promise(() => client.dispose()).pipe( + Effect.andThen(Effect.promise(() => server.stop(true))), + ), + ), + ); }); }); diff --git a/packages/api/src/bun.ts b/packages/api/src/bun.ts index 1b90590435..9b80608494 100644 --- a/packages/api/src/bun.ts +++ b/packages/api/src/bun.ts @@ -7,18 +7,15 @@ import { type SupabaseApiClientOptions, type SupabaseApiConfig } from "./interna import { makePromiseClient, type PromiseClient } from "./internal/promise-client.ts"; /** Creates a Promise API client backed by a managed Bun runtime. */ -export async function createApiClient( +export function createApiClient( config: SupabaseApiConfig = {}, options?: SupabaseApiClientOptions, ): Promise { const runtime = ManagedRuntime.make(Layer.mergeAll(BunServices.layer, FetchHttpClient.layer)); - try { - const effectClient = await runtime.runPromise(makeApiClient(config, options)); - return makePromiseClient(runtime, effectClient); - } catch (error) { - await runtime.dispose(); - throw error; - } + return runtime + .runPromise(makeApiClient(config, options)) + .then((effectClient) => makePromiseClient(runtime, effectClient)) + .catch((error: unknown) => runtime.dispose().then(() => Promise.reject(error))); } /** Promise API client whose runtime resources are released by `dispose`. */ diff --git a/packages/api/src/config/api-config.layer.unit.test.ts b/packages/api/src/config/api-config.layer.unit.test.ts index 0951f499c5..086497f06b 100644 --- a/packages/api/src/config/api-config.layer.unit.test.ts +++ b/packages/api/src/config/api-config.layer.unit.test.ts @@ -1,27 +1,29 @@ import { describe, expect, test } from "vitest"; -import { ConfigProvider, Effect, Option } from "effect"; +import { ConfigProvider, Effect, Layer, Option } from "effect"; import { apiConfigLayer, DEFAULT_SUPABASE_API_URL } from "./api-config.layer.ts"; import { ApiConfig } from "./api-config.service.ts"; describe("apiConfigLayer", () => { - test("defaults the API URL and reads the access token from config", async () => { - const config = await Effect.runPromise( + test("defaults the API URL and reads the access token from config", () => + Effect.runPromise( Effect.gen(function* () { - return yield* ApiConfig; - }).pipe( - Effect.provide(apiConfigLayer), - Effect.provide( - ConfigProvider.layer( - ConfigProvider.fromUnknown({ - SUPABASE_ACCESS_TOKEN: "env-token", - }), + const config = yield* Effect.service(ApiConfig).pipe( + Effect.provide( + apiConfigLayer.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + SUPABASE_ACCESS_TOKEN: "env-token", + }), + ), + ), + ), ), - ), - ), - ); + ); - expect(config.baseUrl).toBe(DEFAULT_SUPABASE_API_URL); - expect(Option.isSome(config.accessToken)).toBe(true); - }); + expect(config.baseUrl).toBe(DEFAULT_SUPABASE_API_URL); + expect(Option.isSome(config.accessToken)).toBe(true); + }), + )); }); diff --git a/packages/api/src/effect.unit.test.ts b/packages/api/src/effect.unit.test.ts index 93249f8096..61098ed1c4 100644 --- a/packages/api/src/effect.unit.test.ts +++ b/packages/api/src/effect.unit.test.ts @@ -91,10 +91,10 @@ describe("SSO provider contracts", () => { test("decodes SSO provider responses without nested SAML and domain IDs", () => { expect(() => - Schema.decodeUnknownSync(V1ListAllSsoProviderOutput)({ items: [SPARSE_PROVIDER] }), + Schema.decodeSync(V1ListAllSsoProviderOutput)({ items: [SPARSE_PROVIDER] }), ).not.toThrow(); for (const schema of SINGLE_PROVIDER_SCHEMAS) { - expect(() => Schema.decodeUnknownSync(schema)(SPARSE_PROVIDER)).not.toThrow(); + expect(() => Schema.decodeSync(schema)(SPARSE_PROVIDER)).not.toThrow(); } }); @@ -106,7 +106,7 @@ describe("SSO provider contracts", () => { }; for (const schema of SINGLE_PROVIDER_SCHEMAS) { - const decoded = Schema.decodeUnknownSync(schema)(withLegacyIds); + const decoded = Schema.decodeSync(schema)(withLegacyIds); expect(decoded.saml).not.toHaveProperty("id"); expect(decoded.domains?.[0]).not.toHaveProperty("id"); } @@ -114,7 +114,7 @@ describe("SSO provider contracts", () => { test("accepts object-valued SSO attribute mapping defaults", () => { expect(() => - Schema.decodeUnknownSync(V1CreateASsoProviderInput)({ + Schema.decodeSync(V1CreateASsoProviderInput)({ ref: "abcdefghijklmnopqrst", type: "saml", attribute_mapping: { @@ -130,7 +130,7 @@ describe("SSO provider contracts", () => { describe("database OpenAPI response contract", () => { test("accepts a normal non-empty OpenAPI document", () => { expect(() => - Schema.decodeUnknownSync(V1GetDatabaseOpenapiOutput)({ + Schema.decodeSync(V1GetDatabaseOpenapiOutput)({ openapi: "3.0.0", info: { title: "Example", version: "1.0.0" }, paths: { @@ -146,257 +146,71 @@ describe("database OpenAPI response contract", () => { }); describe("makeApiClient", () => { - test("allows raw operations to override generated request headers", async () => { - let accept: string | undefined; - - const client = await Effect.runPromise( - makeApiClient(config).pipe( - Effect.provide( - httpClientLayer((request) => { - accept = request.headers.accept; - return Effect.succeed(jsonResponse(request, 200, {})); - }), - ), - ), - ); - - await Effect.runPromise( - client.executeRaw( - operationDefinitions.v1GetAFunctionBody, - { - ref: "abcdefghijklmnopqrst", - function_slug: "hello-world", - }, - { headers: { Accept: "multipart/form-data" } }, - ), - ); - - expect(accept).toBe("multipart/form-data"); - }); + test("allows raw operations to override generated request headers", () => + Effect.runPromise( + Effect.gen(function* () { + let accept: string | undefined; + + const client = yield* makeApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + accept = request.headers.accept; + return Effect.succeed(jsonResponse(request, 200, {})); + }), + ), + ); - test("forwards per-request options through generated typed methods", async () => { - let seen: { url: string; authorization: string | undefined } | undefined; - - const client = await Effect.runPromise( - makeApiClient(config).pipe( - Effect.provide( - httpClientLayer((request) => { - seen = { url: request.url, authorization: request.headers.authorization }; - return Effect.succeed(jsonResponse(request, 200, [])); - }), - ), - ), - ); - - await Effect.runPromise( - client.v1.listAllProjects( - {}, - { baseUrl: "https://api.example.test", accessToken: "per-request-token" }, - ), - ); - - expect(seen).toEqual({ - url: "https://api.example.test/v1/projects", - authorization: "Bearer per-request-token", - }); - }); + yield* client.executeRaw( + operationDefinitions.v1GetAFunctionBody, + { + ref: "abcdefghijklmnopqrst", + function_slug: "hello-world", + }, + { headers: { Accept: "multipart/form-data" } }, + ); - test("uses the default API URL when baseUrl is omitted", async () => { - const seenRequests: Array<{ method: string; url: string }> = []; - - const client = await Effect.runPromise( - makeApiClient({ accessToken: "test-token" }).pipe( - Effect.provide( - httpClientLayer((request) => { - seenRequests.push({ - method: request.method, - url: request.url, - }); - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.0.1", - postgres_engine: "17", - release_channel: "ga", - }, - }), - ); - }), - ), - ), - ); - - await Effect.runPromise( - client.v1.getProject({ - ref: "abcdefghijklmnopqrst", + expect(accept).toBe("multipart/form-data"); }), - ); - - expect(seenRequests).toEqual([ - { - method: "GET", - url: "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst", - }, - ]); - }); - - test("reads the access token from the environment when omitted", async () => { - const seenRequests: Array<{ authorization: string | undefined }> = []; - - const client = await Effect.runPromise( - makeApiClient().pipe( - Effect.provide( - httpClientLayer((request) => { - seenRequests.push({ - authorization: request.headers.authorization, - }); - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.0.1", - postgres_engine: "17", - release_channel: "ga", - }, - }), - ); - }), - ), - Effect.provide( - ConfigProvider.layer( - ConfigProvider.fromUnknown({ - SUPABASE_ACCESS_TOKEN: "env-token", + )); + + test("forwards per-request options through generated typed methods", () => + Effect.runPromise( + Effect.gen(function* () { + let seen: { url: string; authorization: string | undefined } | undefined; + + const client = yield* makeApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + seen = { url: request.url, authorization: request.headers.authorization }; + return Effect.succeed(jsonResponse(request, 200, [])); }), ), - ), - ), - ); + ); - await Effect.runPromise( - client.v1.getProject({ - ref: "abcdefghijklmnopqrst", - }), - ); + yield* client.v1.listAllProjects( + {}, + { baseUrl: "https://api.example.test", accessToken: "per-request-token" }, + ); - expect(seenRequests).toEqual([{ authorization: "Bearer env-token" }]); - }); - - test("passes configured default headers through the facade client", async () => { - const seenRequests: Array<{ - command: string | undefined; - commandRunId: string | undefined; - authorization: string | undefined; - }> = []; - - const client = await Effect.runPromise( - makeApiClient({ - ...config, - headers: { - "X-Supabase-Command": "projects get", - "X-Supabase-Command-Run-ID": "run-456", - }, - }).pipe( - Effect.provide( - httpClientLayer((request) => { - seenRequests.push({ - command: request.headers["x-supabase-command"], - commandRunId: request.headers["x-supabase-command-run-id"], - authorization: request.headers.authorization, - }); - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.0.1", - postgres_engine: "17", - release_channel: "ga", - }, - }), - ); - }), - ), - ), - ); - - await Effect.runPromise( - client.v1.getProject({ - ref: "abcdefghijklmnopqrst", + expect(seen).toEqual({ + url: "https://api.example.test/v1/projects", + authorization: "Bearer per-request-token", + }); }), - ); - - expect(seenRequests).toEqual([ - { - command: "projects get", - commandRunId: "run-456", - authorization: "Bearer test-token", - }, - ]); - }); - - test("fails early when no access token is configured", async () => { - const exit = await Effect.runPromise( - makeApiClient().pipe( - Effect.exit, - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - jsonResponse(request, 200, { - ok: true, - }), - ), - ), - ), - Effect.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({}))), - ), - ); - - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(String(exit.cause)).toContain("Missing access token"); - } - }); - - test("returns only versioned methods under the v1 namespace", async () => { - const seenRequests: Array<{ method: string; url: string }> = []; - - const client = await Effect.runPromise( - makeApiClient(config).pipe( - Effect.provide( - httpClientLayer((request) => { - seenRequests.push({ - method: request.method, - url: request.url, - }); - - if ( - request.method === "POST" && - request.url === "https://api.supabase.com/v1/projects" - ) { + )); + + test("uses the default API URL when baseUrl is omitted", () => + Effect.runPromise( + Effect.gen(function* () { + const seenRequests: Array<{ method: string; url: string }> = []; + + const client = yield* makeApiClient({ accessToken: "test-token" }).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequests.push({ + method: request.method, + url: request.url, + }); return Effect.succeed( jsonResponse(request, 200, { id: "project-id", @@ -407,17 +221,45 @@ describe("makeApiClient", () => { region: "us-east-1", created_at: "2026-03-13T12:00:00.000Z", status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.0.1", + postgres_engine: "17", + release_channel: "ga", + }, }), ); - } + }), + ), + ); - if ( - request.method === "GET" && - request.url === "https://api.supabase.com/v1/projects" - ) { - return Effect.succeed( - jsonResponse(request, 200, [ - { + yield* client.v1.getProject({ + ref: "abcdefghijklmnopqrst", + }); + + expect(seenRequests).toEqual([ + { + method: "GET", + url: "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst", + }, + ]); + }), + )); + + test("reads the access token from the environment when omitted", () => + Effect.runPromise( + Effect.gen(function* () { + const seenRequests: Array<{ authorization: string | undefined }> = []; + + const client = yield* makeApiClient().pipe( + Effect.provide( + Layer.mergeAll( + httpClientLayer((request) => { + seenRequests.push({ + authorization: request.headers.authorization, + }); + return Effect.succeed( + jsonResponse(request, 200, { id: "project-id", ref: "abcdefghijklmnopqrst", organization_id: "org-id", @@ -432,185 +274,336 @@ describe("makeApiClient", () => { postgres_engine: "17", release_channel: "ga", }, - }, - ]), - ); - } - - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.0.1", - postgres_engine: "17", - release_channel: "ga", - }, + }), + ); }), - ); - }), - ), - ), - ); - - expect("createAProject" in client).toBe(false); - expect("getProject" in client).toBe(false); - expect("listAllProjects" in client).toBe(false); - expect(typeof client.v1.createAProject).toBe("function"); - expect(typeof client.v1.getProject).toBe("function"); - expect(typeof client.v1.listAllProjects).toBe("function"); - - const created = await Effect.runPromise( - client.v1.createAProject({ - db_pass: "hunter2", - name: "project-name", - organization_slug: "my-org", - }), - ); - const project = await Effect.runPromise( - client.v1.getProject({ - ref: "abcdefghijklmnopqrst", + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + SUPABASE_ACCESS_TOKEN: "env-token", + }), + ), + ), + ), + ); + + yield* client.v1.getProject({ + ref: "abcdefghijklmnopqrst", + }); + + expect(seenRequests).toEqual([{ authorization: "Bearer env-token" }]); }), - ); - const projects = await Effect.runPromise(client.v1.listAllProjects()); + )); + + test("passes configured default headers through the facade client", () => + Effect.runPromise( + Effect.gen(function* () { + const seenRequests: Array<{ + command: string | undefined; + commandRunId: string | undefined; + authorization: string | undefined; + }> = []; + + const client = yield* makeApiClient({ + ...config, + headers: { + "X-Supabase-Command": "projects get", + "X-Supabase-Command-Run-ID": "run-456", + }, + }).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequests.push({ + command: request.headers["x-supabase-command"], + commandRunId: request.headers["x-supabase-command-run-id"], + authorization: request.headers.authorization, + }); + return Effect.succeed( + jsonResponse(request, 200, { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.0.1", + postgres_engine: "17", + release_channel: "ga", + }, + }), + ); + }), + ), + ); - expect(created.ref).toBe("abcdefghijklmnopqrst"); - expect(project.database.host).toBe("db.supabase.internal"); - expect(projects).toHaveLength(1); - expect(seenRequests).toEqual([ - { - method: "POST", - url: "https://api.supabase.com/v1/projects", - }, - { - method: "GET", - url: "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst", - }, - { - method: "GET", - url: "https://api.supabase.com/v1/projects", - }, - ]); - }); + yield* client.v1.getProject({ + ref: "abcdefghijklmnopqrst", + }); - test("addresses same-named v1 and v2 operations independently by namespace", async () => { - const seenRequests: Array<{ method: string; url: string }> = []; + expect(seenRequests).toEqual([ + { + command: "projects get", + commandRunId: "run-456", + authorization: "Bearer test-token", + }, + ]); + }), + )); + + test("fails early when no access token is configured", () => + Effect.runPromise( + Effect.gen(function* () { + const exit = yield* makeApiClient().pipe( + Effect.exit, + Effect.provide( + Layer.mergeAll( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + ok: true, + }), + ), + ), + ConfigProvider.layer(ConfigProvider.fromUnknown({})), + ), + ), + ); - const client = await Effect.runPromise( - makeApiClient(config).pipe( - Effect.provide( - httpClientLayer((request) => { - seenRequests.push({ - method: request.method, - url: request.url, - }); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(String(exit.cause)).toContain("Missing access token"); + } + }), + )); + + test("returns only versioned methods under the v1 namespace", () => + Effect.runPromise( + Effect.gen(function* () { + const seenRequests: Array<{ method: string; url: string }> = []; + + const client = yield* makeApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequests.push({ + method: request.method, + url: request.url, + }); + + if ( + request.method === "POST" && + request.url === "https://api.supabase.com/v1/projects" + ) { + return Effect.succeed( + jsonResponse(request, 200, { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + }), + ); + } + + if ( + request.method === "GET" && + request.url === "https://api.supabase.com/v1/projects" + ) { + return Effect.succeed( + jsonResponse(request, 200, [ + { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.0.1", + postgres_engine: "17", + release_channel: "ga", + }, + }, + ]), + ); + } - if (request.url === "https://api.supabase.com/v1/organizations/my-org/members") { return Effect.succeed( - jsonResponse(request, 200, [ - { - user_id: "user-id", - user_name: "user-name", - role_name: "Owner", - mfa_enabled: false, - avatar_url: null, + jsonResponse(request, 200, { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.supabase.internal", + version: "17.0.1", + postgres_engine: "17", + release_channel: "ga", }, - ]), + }), ); - } + }), + ), + ); + + expect("createAProject" in client).toBe(false); + expect("getProject" in client).toBe(false); + expect("listAllProjects" in client).toBe(false); + expect(typeof client.v1.createAProject).toBe("function"); + expect(typeof client.v1.getProject).toBe("function"); + expect(typeof client.v1.listAllProjects).toBe("function"); + + const created = yield* client.v1.createAProject({ + db_pass: "hunter2", + name: "project-name", + organization_slug: "my-org", + }); + const project = yield* client.v1.getProject({ + ref: "abcdefghijklmnopqrst", + }); + const projects = yield* client.v1.listAllProjects(); + + expect(created.ref).toBe("abcdefghijklmnopqrst"); + expect(project.database.host).toBe("db.supabase.internal"); + expect(projects).toHaveLength(1); + expect(seenRequests).toEqual([ + { + method: "POST", + url: "https://api.supabase.com/v1/projects", + }, + { + method: "GET", + url: "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst", + }, + { + method: "GET", + url: "https://api.supabase.com/v1/projects", + }, + ]); + }), + )); + + test("addresses same-named v1 and v2 operations independently by namespace", () => + Effect.runPromise( + Effect.gen(function* () { + const seenRequests: Array<{ method: string; url: string }> = []; + + const client = yield* makeApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequests.push({ + method: request.method, + url: request.url, + }); + + if (request.url === "https://api.supabase.com/v1/organizations/my-org/members") { + return Effect.succeed( + jsonResponse(request, 200, [ + { + user_id: "user-id", + user_name: "user-name", + role_name: "Owner", + mfa_enabled: false, + avatar_url: null, + }, + ]), + ); + } - return Effect.succeed( - jsonResponse(request, 200, { - data: [], - links: { prev: null, next: null }, - }), - ); - }), - ), - ), - ); - - expect(typeof client.v1.listOrganizationMembers).toBe("function"); - expect(typeof client.v2.listOrganizationMembers).toBe("function"); - - const v1Members = await Effect.runPromise( - client.v1.listOrganizationMembers({ slug: "my-org" }), - ); - const v2Members = await Effect.runPromise( - client.v2.listOrganizationMembers({ slug: "my-org" }), - ); - - expect(v1Members).toEqual([ - { - user_id: "user-id", - user_name: "user-name", - role_name: "Owner", - mfa_enabled: false, - avatar_url: null, - }, - ]); - expect(v2Members.data).toEqual([]); - expect(seenRequests).toEqual([ - { - method: "GET", - url: "https://api.supabase.com/v1/organizations/my-org/members", - }, - { - method: "GET", - url: "https://api.supabase.com/v2/organizations/my-org/members", - }, - ]); - }); + return Effect.succeed( + jsonResponse(request, 200, { + data: [], + links: { prev: null, next: null }, + }), + ); + }), + ), + ); - test("serializes generated binary methods through the effect facade", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - - const client = await Effect.runPromise( - makeApiClient(config).pipe( - Effect.provide( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed( - jsonResponse(request, 201, { - id: "function-id", - slug: "demo", - name: "Demo Function", - status: "ACTIVE", - version: 1, - created_at: 1_710_000_000, - updated_at: 1_710_000_001, - verify_jwt: true, - entrypoint_path: "functions/demo/index.ts", - import_map_path: "functions/demo/deno.json", - ezbr_sha256: "abc123", - }), - ); - }), - ), - ), - ); - - const body = new Blob(["console.log('blob body');"]); - const result = await Effect.runPromise( - client.v1.createAFunction({ - ref: "abcdefghijklmnopqrst", - slug: "demo", - verify_jwt: true, - entrypoint_path: "functions/demo/index.ts", - body, + expect(typeof client.v1.listOrganizationMembers).toBe("function"); + expect(typeof client.v2.listOrganizationMembers).toBe("function"); + + const v1Members = yield* client.v1.listOrganizationMembers({ slug: "my-org" }); + const v2Members = yield* client.v2.listOrganizationMembers({ slug: "my-org" }); + + expect(v1Members).toEqual([ + { + user_id: "user-id", + user_name: "user-name", + role_name: "Owner", + mfa_enabled: false, + avatar_url: null, + }, + ]); + expect(v2Members.data).toEqual([]); + expect(seenRequests).toEqual([ + { + method: "GET", + url: "https://api.supabase.com/v1/organizations/my-org/members", + }, + { + method: "GET", + url: "https://api.supabase.com/v2/organizations/my-org/members", + }, + ]); }), - ); + )); - expect(result.slug).toBe("demo"); - expect(seenRequest?.headers["content-type"]).toBe("application/vnd.denoland.eszip"); - expect(new URL(seenRequest!.url).pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions"); - expect(requestBodyText(seenRequest!)).toBe("console.log('blob body');"); - }); + test("serializes generated binary methods through the effect facade", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + + const client = yield* makeApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed( + jsonResponse(request, 201, { + id: "function-id", + slug: "demo", + name: "Demo Function", + status: "ACTIVE", + version: 1, + created_at: 1_710_000_000, + updated_at: 1_710_000_001, + verify_jwt: true, + entrypoint_path: "functions/demo/index.ts", + import_map_path: "functions/demo/deno.json", + ezbr_sha256: "abc123", + }), + ); + }), + ), + ); + + const body = new Blob(["console.log('blob body');"]); + const result = yield* client.v1.createAFunction({ + ref: "abcdefghijklmnopqrst", + slug: "demo", + verify_jwt: true, + entrypoint_path: "functions/demo/index.ts", + body, + }); + + expect(result.slug).toBe("demo"); + expect(seenRequest?.headers["content-type"]).toBe("application/vnd.denoland.eszip"); + expect(new URL(seenRequest!.url).pathname).toBe( + "/v1/projects/abcdefghijklmnopqrst/functions", + ); + expect(requestBodyText(seenRequest!)).toBe("console.log('blob body');"); + }), + )); }); diff --git a/packages/api/src/entrypoints.unit.test.ts b/packages/api/src/entrypoints.unit.test.ts index ca5dadfcac..6519dadfed 100644 --- a/packages/api/src/entrypoints.unit.test.ts +++ b/packages/api/src/entrypoints.unit.test.ts @@ -1,7 +1,7 @@ -import { existsSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem, Path, Schema } from "effect"; import { describe, expect, test } from "vitest"; import * as effectModule from "./effect.ts"; @@ -26,32 +26,45 @@ describe("@supabase/api entrypoints", () => { expect(effectModule.DEFAULT_SUPABASE_API_URL).toBe("https://api.supabase.com"); }); - test("does not generate separate promise or standalone operation artifacts", () => { - const srcDir = dirname(fileURLToPath(import.meta.url)); - expect(existsSync(join(srcDir, "generated/promise-client.ts"))).toBe(false); - expect(existsSync(join(srcDir, "generated/effect-operations.ts"))).toBe(false); - }); + test("does not generate separate promise or standalone operation artifacts", () => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const srcDir = path.dirname(fileURLToPath(import.meta.url)); + expect(yield* fs.exists(path.join(srcDir, "generated/promise-client.ts"))).toBe(false); + expect(yield* fs.exists(path.join(srcDir, "generated/effect-operations.ts"))).toBe(false); + }).pipe(Effect.provide(NodeServices.layer)), + )); - test("ships the OpenAPI spec as a json subpath artifact", () => { - const srcDir = dirname(fileURLToPath(import.meta.url)); - const packageJson = JSON.parse(readFileSync(join(srcDir, "../package.json"), "utf8")) as { - readonly exports: Record>; - }; - const openApiDocument = JSON.parse( - readFileSync(join(srcDir, "generated/openapi.json"), "utf8"), - ) as { readonly openapi: string }; + test("ships the OpenAPI spec as a json subpath artifact", () => + Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); + const srcDir = path.dirname(fileURLToPath(import.meta.url)); + const packageJson = yield* Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ exports: Schema.Record(Schema.String, Schema.Unknown) }), + ), + )(decoder.decode(yield* fs.readFile(path.join(srcDir, "../package.json")))); + const openApiDocument = yield* Schema.decodeEffect( + Schema.fromJsonString(Schema.Struct({ openapi: Schema.Unknown })), + )(decoder.decode(yield* fs.readFile(path.join(srcDir, "generated/openapi.json")))); - expect(packageJson.exports["."]).toEqual({ - bun: "./src/bun.ts", - default: "./src/node.ts", - }); - expect(packageJson.exports["./effect"]).toBe("./src/effect.ts"); - expect(packageJson.exports["./permissions"]).toBe("./src/generated/permissions.ts"); - expect(packageJson.exports["./openapi.json"]).toBe("./src/generated/openapi.json"); - expect(packageJson.exports["./bun"]).toBeUndefined(); - expect(packageJson.exports["./node"]).toBeUndefined(); - expect(openApiDocument.openapi).toBe("3.0.0"); - }); + expect(packageJson.exports["."]).toEqual({ + bun: "./src/bun.ts", + default: "./src/node.ts", + }); + expect(packageJson.exports["./effect"]).toBe("./src/effect.ts"); + expect(packageJson.exports["./permissions"]).toBe("./src/generated/permissions.ts"); + expect(packageJson.exports["./openapi.json"]).toBe("./src/generated/openapi.json"); + expect(packageJson.exports["./bun"]).toBeUndefined(); + expect(packageJson.exports["./node"]).toBeUndefined(); + expect(openApiDocument.openapi).toBe("3.0.0"); + }).pipe(Effect.provide(NodeServices.layer)), + )); test("exports a stable raw OpenAPI operation id map", () => { expect(Object.keys(effectModule.openApiOperationIdMap)).toHaveLength( diff --git a/packages/api/src/generated-contract-sync.unit.test.ts b/packages/api/src/generated-contract-sync.unit.test.ts index b103cbdf2b..e5cc25efc1 100644 --- a/packages/api/src/generated-contract-sync.unit.test.ts +++ b/packages/api/src/generated-contract-sync.unit.test.ts @@ -1,7 +1,7 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { NodeServices } from "@effect/platform-node"; +import { Effect, FileSystem, Schema } from "effect"; import { describe, expect, test } from "vitest"; import { openApiOperationIdMap, operationDefinitions } from "./generated/contracts.ts"; @@ -25,8 +25,12 @@ interface SnapshotOperation { readonly operationId: string; } -const openApiJsonPath = join(dirname(fileURLToPath(import.meta.url)), "generated/openapi.json"); -const rawOpenApiJson = readFileSync(openApiJsonPath, "utf8"); +const openApiJsonPath = fileURLToPath(new URL("./generated/openapi.json", import.meta.url)); +const readOpenApiJson = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return new TextDecoder("utf-8", { ignoreBOM: true }).decode(yield* fs.readFile(openApiJsonPath)); +}).pipe(Effect.provide(NodeServices.layer)); +const rawOpenApiJson = await Effect.runPromise(readOpenApiJson); const openApiDocument = JSON.parse(rawOpenApiJson) as OpenApiDocumentShape; function extractSnapshotOperations( @@ -173,10 +177,15 @@ describe("generated client drift against the committed openapi.json snapshot", ( // committed file doesn't hold, since oxfmt collapses short arrays onto one line // after generation. This instead checks that the bytes parse deterministically // and keep a single trailing newline. - test("parses the committed snapshot deterministically and keeps a single trailing newline", () => { - const reparsed = JSON.parse(readFileSync(openApiJsonPath, "utf8")) as OpenApiDocumentShape; - expect(reparsed).toEqual(openApiDocument); - expect(rawOpenApiJson.endsWith("\n")).toBe(true); - expect(rawOpenApiJson.endsWith("\n\n")).toBe(false); - }); + test("parses the committed snapshot deterministically and keeps a single trailing newline", () => + Effect.runPromise( + Effect.gen(function* () { + const reparsed = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + yield* readOpenApiJson, + ); + expect(reparsed).toEqual(openApiDocument); + expect(rawOpenApiJson.endsWith("\n")).toBe(true); + expect(rawOpenApiJson.endsWith("\n\n")).toBe(false); + }), + )); }); diff --git a/packages/api/src/internal/client.ts b/packages/api/src/internal/client.ts index 7a4d3a0855..48a5f4f0d7 100644 --- a/packages/api/src/internal/client.ts +++ b/packages/api/src/internal/client.ts @@ -1,5 +1,6 @@ import { Clock, Data, Duration, Effect, Option, Context, Random, Schedule } from "effect"; import * as Cause from "effect/Cause"; +import { NodeInspectSymbol } from "effect/Inspectable"; import * as Redacted from "effect/Redacted"; import type { SchemaError } from "effect/Schema"; import * as Schema from "effect/Schema"; @@ -97,12 +98,22 @@ export class SupabaseApiClient extends Context.Service { + override readonly name = "SupabaseApiConfigError"; + + static { + /* Keep native Error inspect output instead of Effect's toJSON view. */ + Object.defineProperty(this.prototype, NodeInspectSymbol, { value: undefined }); + } constructor(message: string) { - super(message); - this.name = "SupabaseApiConfigError"; + super({ message }); + } + + override toJSON(): unknown { + return { _tag: this._tag, name: this.name }; } } @@ -114,17 +125,32 @@ export type SupabaseApiInputErrorSource = "generated_client" | "user_input"; * bug; command boundaries can reclassify a confirmed user-derived request as * `user_input`. The original schema failure is preserved as `cause`. */ -export class SupabaseApiInputError extends Error { - readonly _tag = "SupabaseApiInputError"; +export class SupabaseApiInputError extends Data.TaggedError("SupabaseApiInputError")<{ + readonly message: string; + readonly cause?: unknown; +}> { + override readonly name = "SupabaseApiInputError"; #source: SupabaseApiInputErrorSource = "generated_client"; + static { + /* Keep native Error inspect output instead of Effect's toJSON view. */ + Object.defineProperty(this.prototype, NodeInspectSymbol, { value: undefined }); + } + get source(): SupabaseApiInputErrorSource { return this.#source; } constructor(message: string, options?: { readonly cause?: unknown }) { - super(message, options); - this.name = "SupabaseApiInputError"; + super( + options === undefined || !("cause" in options) + ? { message } + : { message, cause: options.cause }, + ); + } + + override toJSON(): unknown { + return { _tag: this._tag, name: this.name }; } static markAsUserInput(error: T): T { @@ -146,10 +172,8 @@ function resolveSupabaseApiConfig( const accessToken = config.accessToken ?? Option.getOrUndefined(apiConfig.accessToken); if (accessToken === undefined) { - return yield* Effect.fail( - new SupabaseApiConfigError( - "Missing access token. Provide `accessToken` or set `SUPABASE_ACCESS_TOKEN`.", - ), + return yield* new SupabaseApiConfigError( + "Missing access token. Provide `accessToken` or set `SUPABASE_ACCESS_TOKEN`.", ); } @@ -505,7 +529,7 @@ function asBinaryRequestBody(value: unknown): Effect.Effect new Uint8Array(await revealed.arrayBuffer()), + try: () => revealed.arrayBuffer().then((buffer) => new Uint8Array(buffer)), catch: (cause) => new HttpBody.HttpBodyError({ reason: { _tag: "JsonError" }, cause }), }); } diff --git a/packages/api/src/internal/client.unit.test.ts b/packages/api/src/internal/client.unit.test.ts index dbd83aa270..5ce9c56fe1 100644 --- a/packages/api/src/internal/client.unit.test.ts +++ b/packages/api/src/internal/client.unit.test.ts @@ -11,6 +11,7 @@ import { Random, Redacted, } from "effect"; +import { NodeInspectSymbol } from "effect/Inspectable"; import { TestClock } from "effect/testing"; import * as HttpBody from "effect/http/HttpBody"; import * as HttpClient from "effect/http/HttpClient"; @@ -27,6 +28,7 @@ import { type SupabaseApiClientOptions, type SupabaseApiClientShape, type SupabaseApiRetryOptions, + SupabaseApiConfigError, SupabaseApiInputError, } from "./client.ts"; @@ -136,15 +138,10 @@ function formDataTextValue(formData: FormData, key: string): string { return value; } -async function formDataFileTexts(formData: FormData, key: string): Promise> { +function formDataFileTexts(formData: FormData, key: string): Effect.Effect> { const values = formData.getAll(key); - return Promise.all( - values.map(async (value) => { - if (typeof value === "string") { - return value; - } - return value.text(); - }), + return Effect.forEach(values, (value) => + typeof value === "string" ? Effect.succeed(value) : Effect.promise(() => value.text()), ); } @@ -178,15 +175,13 @@ const config = { function onVirtualClock( effect: Effect.Effect, jitter = 0.5, -): Promise { - return Effect.runPromise( - effect.pipe( - Effect.provideService(Random.Random, { - nextIntUnsafe: () => 0, - nextDoubleUnsafe: () => jitter, - }), - Effect.provide(TestClock.layer()), - ), +): Effect.Effect { + return effect.pipe( + Effect.provideService(Random.Random, { + nextIntUnsafe: () => 0, + nextDoubleUnsafe: () => jitter, + }), + Effect.provide(TestClock.layer()), ); } @@ -229,994 +224,1026 @@ const listProjects = (client: SupabaseApiClientShape) => client.execute<"v1ListAllProjects">(operationDefinitions.v1ListAllProjects, {}); describe("makeSupabaseApiClient", () => { - test("defaults request-schema failures to generated-client provenance", async () => { - let requests = 0; - const client = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.provide( - httpClientLayer((request) => { - requests += 1; - return Effect.succeed(jsonResponse(request, 200, {})); - }), - ), - ), - ); - - const executeError = await Effect.runPromise( - client - .execute(operationDefinitions.v1DeleteAFunction, { - ref: "invalid-ref", - function_slug: "hello-world", - }) - .pipe(Effect.flip), - ); - const executeRawError = await Effect.runPromise( - client - .executeRaw(operationDefinitions.v1DeleteAFunction, { - ref: "invalid-ref", - function_slug: "hello-world", - }) - .pipe(Effect.flip), - ); - - for (const error of [executeError, executeRawError]) { - expect(error).toBeInstanceOf(SupabaseApiInputError); - if (!(error instanceof SupabaseApiInputError)) { - throw new Error("expected SupabaseApiInputError"); - } - expect(error.source).toBe("generated_client"); - } + test("defaults request-schema failures to generated-client provenance", () => + Effect.runPromise( + Effect.gen(function* () { + let requests = 0; + const client = yield* makeSupabaseApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + requests += 1; + return Effect.succeed(jsonResponse(request, 200, {})); + }), + ), + ); - if (!(executeRawError instanceof SupabaseApiInputError)) { - throw new Error("expected SupabaseApiInputError"); - } - expect(markSupabaseApiInputErrorAsUserInput(executeRawError)).toBe(executeRawError); - expect(executeRawError.source).toBe("user_input"); - expect(requests).toBe(0); - }); + const executeError = yield* client + .execute(operationDefinitions.v1DeleteAFunction, { + ref: "invalid-ref", + function_slug: "hello-world", + }) + .pipe(Effect.flip); + const executeRawError = yield* client + .executeRaw(operationDefinitions.v1DeleteAFunction, { + ref: "invalid-ref", + function_slug: "hello-world", + }) + .pipe(Effect.flip); + + for (const error of [executeError, executeRawError]) { + expect(error).toBeInstanceOf(SupabaseApiInputError); + if (!(error instanceof SupabaseApiInputError)) { + throw new Error("expected SupabaseApiInputError"); + } + expect(error.source).toBe("generated_client"); + } - test("rejects unknown request keys at any depth before sending a request", async () => { - let requests = 0; - const client = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.provide( - httpClientLayer((request) => { - requests += 1; - return Effect.succeed(jsonResponse(request, 200, {})); - }), - ), - ), - ); - const ref = "abcdefghijklmnopqrst"; - const topLevelExcess = { ref, requestedConfig: { database: true }, junk: 1 }; - const nestedExcess = { ref, requestedConfig: { database: true, junk: 1 } }; + if (!(executeRawError instanceof SupabaseApiInputError)) { + throw new Error("expected SupabaseApiInputError"); + } + expect(markSupabaseApiInputErrorAsUserInput(executeRawError)).toBe(executeRawError); + expect(executeRawError.source).toBe("user_input"); + expect(requests).toBe(0); + }), + )); - const errors = await Promise.all([ - Effect.runPromise( - client - .execute(operationDefinitions.v1UpdateSslEnforcementConfig, topLevelExcess) - .pipe(Effect.flip), - ), - Effect.runPromise( - client - .executeRaw(operationDefinitions.v1UpdateSslEnforcementConfig, nestedExcess) - .pipe(Effect.flip), - ), - ]); + test("rejects unknown request keys at any depth before sending a request", () => + Effect.runPromise( + Effect.gen(function* () { + let requests = 0; + const client = yield* makeSupabaseApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + requests += 1; + return Effect.succeed(jsonResponse(request, 200, {})); + }), + ), + ); + const ref = "abcdefghijklmnopqrst"; + const topLevelExcess = { ref, requestedConfig: { database: true }, junk: 1 }; + const nestedExcess = { ref, requestedConfig: { database: true, junk: 1 } }; + + const errors = yield* Effect.all( + [ + client + .execute(operationDefinitions.v1UpdateSslEnforcementConfig, topLevelExcess) + .pipe(Effect.flip), + client + .executeRaw(operationDefinitions.v1UpdateSslEnforcementConfig, nestedExcess) + .pipe(Effect.flip), + ], + { concurrency: "unbounded" }, + ); - for (const error of errors) { - expect(error).toBeInstanceOf(SupabaseApiInputError); - } - expect(errors[0]?.message).toContain('["junk"]'); - expect(errors[1]?.message).toContain('["requestedConfig"]["junk"]'); - expect(requests).toBe(0); - }); + for (const error of errors) { + expect(error).toBeInstanceOf(SupabaseApiInputError); + } + expect(errors[0]?.message).toContain('["junk"]'); + expect(errors[1]?.message).toContain('["requestedConfig"]["junk"]'); + expect(requests).toBe(0); + }), + )); - test("per-request options override the base URL, token, and headers for that request only", async () => { - const seen: Array<{ - url: string; - authorization: string | undefined; - userAgent: string | undefined; - custom: string | undefined; - }> = []; - const client = await Effect.runPromise( - makeSupabaseApiClient({ ...config, headers: { "X-Custom": "default" } }).pipe( - Effect.provide( - httpClientLayer((request) => { - seen.push({ - url: request.url, - authorization: request.headers.authorization, - userAgent: request.headers["user-agent"], - custom: request.headers["x-custom"], - }); - return Effect.succeed(jsonResponse(request, 200, [])); - }), - ), - ), - ); + test("per-request options override the base URL, token, and headers for that request only", () => + Effect.runPromise( + Effect.gen(function* () { + const seen: Array<{ + url: string; + authorization: string | undefined; + userAgent: string | undefined; + custom: string | undefined; + }> = []; + const client = yield* makeSupabaseApiClient({ + ...config, + headers: { "X-Custom": "default" }, + }).pipe( + Effect.provide( + httpClientLayer((request) => { + seen.push({ + url: request.url, + authorization: request.headers.authorization, + userAgent: request.headers["user-agent"], + custom: request.headers["x-custom"], + }); + return Effect.succeed(jsonResponse(request, 200, [])); + }), + ), + ); - await Effect.runPromise( - client.execute( - operationDefinitions.v1ListAllProjects, - {}, - { - baseUrl: "https://api.example.test/prefix", - accessToken: Redacted.make("override-token"), - headers: { "X-Custom": "override" }, - }, - ), - ); - await Effect.runPromise( - client.executeRaw( - operationDefinitions.v1ListAllProjects, - {}, - { baseUrl: "https://raw.example.test", accessToken: "raw-token" }, - ), - ); - await Effect.runPromise(client.execute(operationDefinitions.v1ListAllProjects, {})); - - expect(seen).toEqual([ - { - url: "https://api.example.test/prefix/v1/projects", - authorization: "Bearer override-token", - userAgent: "supabase-api/test", - custom: "override", - }, - { - url: "https://raw.example.test/v1/projects", - authorization: "Bearer raw-token", - userAgent: "supabase-api/test", - custom: "default", - }, - { - url: "https://api.supabase.com/v1/projects", - authorization: "Bearer test-token", - userAgent: "supabase-api/test", - custom: "default", - }, - ]); - }); + yield* client.execute( + operationDefinitions.v1ListAllProjects, + {}, + { + baseUrl: "https://api.example.test/prefix", + accessToken: Redacted.make("override-token"), + headers: { "X-Custom": "override" }, + }, + ); + yield* client.executeRaw( + operationDefinitions.v1ListAllProjects, + {}, + { baseUrl: "https://raw.example.test", accessToken: "raw-token" }, + ); + yield* client.execute(operationDefinitions.v1ListAllProjects, {}); + + expect(seen).toEqual([ + { + url: "https://api.example.test/prefix/v1/projects", + authorization: "Bearer override-token", + userAgent: "supabase-api/test", + custom: "override", + }, + { + url: "https://raw.example.test/v1/projects", + authorization: "Bearer raw-token", + userAgent: "supabase-api/test", + custom: "default", + }, + { + url: "https://api.supabase.com/v1/projects", + authorization: "Bearer test-token", + userAgent: "supabase-api/test", + custom: "default", + }, + ]); + }), + )); - test("requests with per-request overrides still pass through transformClient", async () => { - const transformed: Array = []; - const wire: Array<{ url: string; stamp: string | undefined }> = []; - const client = await Effect.runPromise( - makeSupabaseApiClient(config, { - transformClient: (inner) => - Effect.succeed( - HttpClient.mapRequest(inner, (request) => { - transformed.push(request.url); - return HttpClientRequest.setHeader(request, "X-Stamp", "stamped"); + test("requests with per-request overrides still pass through transformClient", () => + Effect.runPromise( + Effect.gen(function* () { + const transformed: Array = []; + const wire: Array<{ url: string; stamp: string | undefined }> = []; + const client = yield* makeSupabaseApiClient(config, { + transformClient: (inner) => + Effect.succeed( + HttpClient.mapRequest(inner, (request) => { + transformed.push(request.url); + return HttpClientRequest.setHeader(request, "X-Stamp", "stamped"); + }), + ), + }).pipe( + Effect.provide( + httpClientLayer((request) => { + wire.push({ url: request.url, stamp: request.headers["x-stamp"] }); + return Effect.succeed(jsonResponse(request, 200, [])); }), ), - }).pipe( - Effect.provide( - httpClientLayer((request) => { - wire.push({ url: request.url, stamp: request.headers["x-stamp"] }); - return Effect.succeed(jsonResponse(request, 200, [])); - }), - ), - ), - ); + ); - await Effect.runPromise( - client.execute( - operationDefinitions.v1ListAllProjects, - {}, - { baseUrl: "https://api.example.test" }, - ), - ); + yield* client.execute( + operationDefinitions.v1ListAllProjects, + {}, + { baseUrl: "https://api.example.test" }, + ); - expect(transformed).toEqual(["https://api.example.test/v1/projects"]); - expect(wire).toEqual([{ url: "https://api.example.test/v1/projects", stamp: "stamped" }]); - }); + expect(transformed).toEqual(["https://api.example.test/v1/projects"]); + expect(wire).toEqual([{ url: "https://api.example.test/v1/projects", stamp: "stamped" }]); + }), + )); - test("fails request-body construction before sending a request", async () => { - class BrokenBlob extends Blob { - override arrayBuffer(): Promise { - return Promise.reject(new Error("body read failed")); - } - } + test("fails request-body construction before sending a request", () => + Effect.runPromise( + Effect.gen(function* () { + class BrokenBlob extends Blob { + override arrayBuffer(): Promise { + return Promise.reject(new Error("body read failed")); + } + } - let requests = 0; - const error = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.executeRaw(operationDefinitions.v1CreateAFunction, { - ref: "abcdefghijklmnopqrst", - slug: "demo", - body: new BrokenBlob([]), - }), - ), - Effect.provide( - httpClientLayer((request) => { - requests += 1; - return Effect.succeed(functionResponse(request, 201)); - }), - ), - Effect.flip, - ), - ); + let requests = 0; + const error = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.executeRaw(operationDefinitions.v1CreateAFunction, { + ref: "abcdefghijklmnopqrst", + slug: "demo", + body: new BrokenBlob([]), + }), + ), + Effect.provide( + httpClientLayer((request) => { + requests += 1; + return Effect.succeed(functionResponse(request, 201)); + }), + ), + Effect.flip, + ); - expect(error).toBeInstanceOf(HttpBody.HttpBodyError); - expect(requests).toBe(0); - }); + expect(error).toBeInstanceOf(HttpBody.HttpBodyError); + expect(requests).toBe(0); + }), + )); - test("executeRaw forwards extraBody fields and lets validated fields win", async () => { - const bodies: unknown[] = []; - await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.executeRaw( - operationDefinitions.v1UpdatePostgresConfig, - { ref: "abcdefghijklmnopqrst", effective_cache_size: "4GB" }, - { - extraBody: { - effective_cache_size: "1GB", - future_parameter: "on", + test("executeRaw forwards extraBody fields and lets validated fields win", () => + Effect.runPromise( + Effect.gen(function* () { + const bodies: unknown[] = []; + yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.executeRaw( + operationDefinitions.v1UpdatePostgresConfig, + { ref: "abcdefghijklmnopqrst", effective_cache_size: "4GB" }, + { + extraBody: { + effective_cache_size: "1GB", + future_parameter: "on", + }, }, - }, + ), ), - ), - Effect.provide( - httpClientLayer((request) => { - bodies.push(JSON.parse(requestBodyText(request))); - return Effect.succeed(jsonResponse(request, 200, {})); - }), - ), - ), - ); - - expect(bodies).toEqual([{ effective_cache_size: "4GB", future_parameter: "on" }]); - }); - - test("executeRaw rejects extraBody on operations without a JSON field body", async () => { - let requests = 0; - const exit = await Effect.runPromiseExit( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.executeRaw(operationDefinitions.v1ListAllProjects, {}, { extraBody: { a: 1 } }), - ), - Effect.provide( - httpClientLayer((request) => { - requests += 1; - return Effect.succeed(jsonResponse(request, 200, [])); - }), - ), - ), - ); + Effect.provide( + httpClientLayer((request) => { + bodies.push(JSON.parse(requestBodyText(request))); + return Effect.succeed(jsonResponse(request, 200, {})); + }), + ), + ); - expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true); - expect(requests).toBe(0); - }); + expect(bodies).toEqual([{ effective_cache_size: "4GB", future_parameter: "on" }]); + }), + )); - test("retries transport errors for GET requests", async () => { - const { requestTimes, result } = await onVirtualClock( + test("executeRaw rejects extraBody on operations without a JSON field body", () => + Effect.runPromise( Effect.gen(function* () { - const call = yield* startVirtualCall(listProjects, (request, attempt) => - attempt < 3 ? transportError(request, "socket reset") : jsonResponse(request, 200, []), + let requests = 0; + const exit = yield* Effect.exit( + makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.executeRaw( + operationDefinitions.v1ListAllProjects, + {}, + { extraBody: { a: 1 } }, + ), + ), + Effect.provide( + httpClientLayer((request) => { + requests += 1; + return Effect.succeed(jsonResponse(request, 200, [])); + }), + ), + ), ); - yield* TestClock.adjust(Duration.minutes(1)); - return { requestTimes: call.requestTimes, result: yield* Fiber.join(call.fiber) }; - }), - ); - expect(requestTimes).toHaveLength(3); - expect(result).toEqual(Exit.succeed([])); - }); + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true); + expect(requests).toBe(0); + }), + )); - test("reveals redacted auth tokens only at the transport boundary", async () => { - let authorizationHeader: string | undefined; - - const result = await Effect.runPromise( - makeSupabaseApiClient({ - ...config, - accessToken: Redacted.make("redacted-token"), - }).pipe( - Effect.flatMap((client) => - client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { - db_pass: "hunter2", - name: "project-name", - organization_slug: "my-org", - }), - ), - Effect.provide( - httpClientLayer((request) => { - authorizationHeader = request.headers.authorization; - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - }), + test("retries transport errors for GET requests", () => + Effect.runPromise( + Effect.gen(function* () { + const { requestTimes, result } = yield* onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt < 3 + ? transportError(request, "socket reset") + : jsonResponse(request, 200, []), ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, result: yield* Fiber.join(call.fiber) }; }), - ), - ), - ); + ); - expect(authorizationHeader).toBe("Bearer redacted-token"); - expect(result.ref).toBe("abcdefghijklmnopqrst"); - }); + expect(requestTimes).toHaveLength(3); + expect(result).toEqual(Exit.succeed([])); + }), + )); - test("applies default headers alongside auth, user agent, and request headers", async () => { - let seenHeaders: - | { - authorization: string | undefined; - userAgent: string | undefined; - command: string | undefined; - commandRunId: string | undefined; - idempotencyKey: string | undefined; - } - | undefined; - - await Effect.runPromise( - makeSupabaseApiClient({ - ...config, - headers: { - "X-Supabase-Command": "branches list", - "X-Supabase-Command-Run-ID": "run-123", - }, - }).pipe( - Effect.flatMap((client) => - client.execute<"v1ApplyAMigration">(operationDefinitions.v1ApplyAMigration, { - ref: "abcdefghijklmnopqrst", - query: "select 1", - name: "smoke_test", - "Idempotency-Key": "migration-123", - }), - ), - Effect.provide( - httpClientLayer((request) => { - seenHeaders = { - authorization: request.headers.authorization, - userAgent: request.headers["user-agent"], - command: request.headers["x-supabase-command"], - commandRunId: request.headers["x-supabase-command-run-id"], - idempotencyKey: request.headers["idempotency-key"], - }; - return Effect.succeed( - HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })), - ); - }), - ), - ), - ); + test("reveals redacted auth tokens only at the transport boundary", () => + Effect.runPromise( + Effect.gen(function* () { + let authorizationHeader: string | undefined; + + const result = yield* makeSupabaseApiClient({ + ...config, + accessToken: Redacted.make("redacted-token"), + }).pipe( + Effect.flatMap((client) => + client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { + db_pass: "hunter2", + name: "project-name", + organization_slug: "my-org", + }), + ), + Effect.provide( + httpClientLayer((request) => { + authorizationHeader = request.headers.authorization; + return Effect.succeed( + jsonResponse(request, 200, { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + }), + ); + }), + ), + ); - expect(seenHeaders).toEqual({ - authorization: "Bearer test-token", - userAgent: "supabase-api/test", - command: "branches list", - commandRunId: "run-123", - idempotencyKey: "migration-123", - }); - }); + expect(authorizationHeader).toBe("Bearer redacted-token"); + expect(result.ref).toBe("abcdefghijklmnopqrst"); + }), + )); - test("decodes nullable JWT templates in API key responses", async () => { - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1GetProjectApiKeys">(operationDefinitions.v1GetProjectApiKeys, { - ref: "abcdefghijklmnopqrst", - reveal: true, - }), - ), - Effect.provide( - httpClientLayer((request) => { - const url = requestUrl(request); - expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/api-keys"); - expect(requestUrlParam(request, "reveal")).toBe("true"); - return Effect.succeed( - jsonResponse(request, 200, [ - { - name: "anon", - type: "legacy", - api_key: "anon-key", - secret_jwt_template: null, - }, - { - name: "service_role", - type: "secret", - api_key: "service-role-key", - secret_jwt_template: { role: "service_role" }, - }, - ]), - ); - }), - ), - ), - ); + test("applies default headers alongside auth, user agent, and request headers", () => + Effect.runPromise( + Effect.gen(function* () { + let seenHeaders: + | { + authorization: string | undefined; + userAgent: string | undefined; + command: string | undefined; + commandRunId: string | undefined; + idempotencyKey: string | undefined; + } + | undefined; + + yield* makeSupabaseApiClient({ + ...config, + headers: { + "X-Supabase-Command": "branches list", + "X-Supabase-Command-Run-ID": "run-123", + }, + }).pipe( + Effect.flatMap((client) => + client.execute<"v1ApplyAMigration">(operationDefinitions.v1ApplyAMigration, { + ref: "abcdefghijklmnopqrst", + query: "select 1", + name: "smoke_test", + "Idempotency-Key": "migration-123", + }), + ), + Effect.provide( + httpClientLayer((request) => { + seenHeaders = { + authorization: request.headers.authorization, + userAgent: request.headers["user-agent"], + command: request.headers["x-supabase-command"], + commandRunId: request.headers["x-supabase-command-run-id"], + idempotencyKey: request.headers["idempotency-key"], + }; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })), + ); + }), + ), + ); - expect(result).toEqual([ - { - name: "anon", - type: "legacy", - api_key: "anon-key", - secret_jwt_template: null, - }, - { - name: "service_role", - type: "secret", - api_key: "service-role-key", - secret_jwt_template: { role: "service_role" }, - }, - ]); - }); + expect(seenHeaders).toEqual({ + authorization: "Bearer test-token", + userAgent: "supabase-api/test", + command: "branches list", + commandRunId: "run-123", + idempotencyKey: "migration-123", + }); + }), + )); + + test("decodes nullable JWT templates in API key responses", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1GetProjectApiKeys">(operationDefinitions.v1GetProjectApiKeys, { + ref: "abcdefghijklmnopqrst", + reveal: true, + }), + ), + Effect.provide( + httpClientLayer((request) => { + const url = requestUrl(request); + expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/api-keys"); + expect(requestUrlParam(request, "reveal")).toBe("true"); + return Effect.succeed( + jsonResponse(request, 200, [ + { + name: "anon", + type: "legacy", + api_key: "anon-key", + secret_jwt_template: null, + }, + { + name: "service_role", + type: "secret", + api_key: "service-role-key", + secret_jwt_template: { role: "service_role" }, + }, + ]), + ); + }), + ), + ); + + expect(result).toEqual([ + { + name: "anon", + type: "legacy", + api_key: "anon-key", + secret_jwt_template: null, + }, + { + name: "service_role", + type: "secret", + api_key: "service-role-key", + secret_jwt_template: { role: "service_role" }, + }, + ]); + }), + )); // Regression payloads: the spec's Z-anchored pattern used to reject these and // break `link` and `branches list` outright. - test("decodes timestamps with a numeric UTC offset", async () => { - const apiKeys = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1GetProjectApiKeys">(operationDefinitions.v1GetProjectApiKeys, { - ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - jsonResponse(request, 200, [ - { - name: "anon", - type: "legacy", - api_key: "anon-key", - inserted_at: "2026-05-01T08:00:00+00:00", - updated_at: "2026-05-01T08:00:00.123456+02:00", - }, - ]), + test("decodes timestamps with a numeric UTC offset", () => + Effect.runPromise( + Effect.gen(function* () { + const apiKeys = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1GetProjectApiKeys">(operationDefinitions.v1GetProjectApiKeys, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, [ + { + name: "anon", + type: "legacy", + api_key: "anon-key", + inserted_at: "2026-05-01T08:00:00+00:00", + updated_at: "2026-05-01T08:00:00.123456+02:00", + }, + ]), + ), ), ), - ), - ), - ); + ); - expect(apiKeys[0]?.inserted_at).toBe("2026-05-01T08:00:00+00:00"); - expect(apiKeys[0]?.updated_at).toBe("2026-05-01T08:00:00.123456+02:00"); + expect(apiKeys[0]?.inserted_at).toBe("2026-05-01T08:00:00+00:00"); + expect(apiKeys[0]?.updated_at).toBe("2026-05-01T08:00:00.123456+02:00"); - const branches = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1ListAllBranches">(operationDefinitions.v1ListAllBranches, { - ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - jsonResponse(request, 200, [ - { - id: "6f8f9d2c-1f43-4b8a-9d0e-3a2b1c4d5e6f", - name: "preview", - project_ref: "abcdefghijklmnopqrst", - parent_project_ref: "tsrqponmlkjihgfedcba", - is_default: false, - persistent: false, - status: "MIGRATIONS_PASSED", - with_data: false, - created_at: "2026-08-06T19:27:30.261795+00:00", - updated_at: "2026-08-06T19:27:30.261795+00:00", - }, - ]), + const branches = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1ListAllBranches">(operationDefinitions.v1ListAllBranches, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, [ + { + id: "6f8f9d2c-1f43-4b8a-9d0e-3a2b1c4d5e6f", + name: "preview", + project_ref: "abcdefghijklmnopqrst", + parent_project_ref: "tsrqponmlkjihgfedcba", + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + with_data: false, + created_at: "2026-08-06T19:27:30.261795+00:00", + updated_at: "2026-08-06T19:27:30.261795+00:00", + }, + ]), + ), ), ), - ), - ), - ); + ); - expect(branches[0]?.created_at).toBe("2026-08-06T19:27:30.261795+00:00"); - }); + expect(branches[0]?.created_at).toBe("2026-08-06T19:27:30.261795+00:00"); + }), + )); - test("accepts missing custom-hostname SSL validation records", async () => { - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1GetHostnameConfig">(operationDefinitions.v1GetHostnameConfig, { - ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - jsonResponse(request, 200, { - status: "2_initiated", - custom_hostname: "shop.acme.dev", - data: { - success: true, - errors: [], - messages: [], - result: { - id: "hostname-id", - hostname: "shop.acme.dev", - ssl: { - status: "pending_validation", - }, - ownership_verification: { - type: "txt", - name: "_cf-custom-hostname.shop.acme.dev", - value: "verification-token", + test("accepts missing custom-hostname SSL validation records", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1GetHostnameConfig">(operationDefinitions.v1GetHostnameConfig, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + status: "2_initiated", + custom_hostname: "shop.acme.dev", + data: { + success: true, + errors: [], + messages: [], + result: { + id: "hostname-id", + hostname: "shop.acme.dev", + ssl: { + status: "pending_validation", + }, + ownership_verification: { + type: "txt", + name: "_cf-custom-hostname.shop.acme.dev", + value: "verification-token", + }, + custom_origin_server: "abcdefghijklmnopqrst.supabase.co", + status: "pending", }, - custom_origin_server: "abcdefghijklmnopqrst.supabase.co", - status: "pending", }, - }, - }), + }), + ), ), ), - ), - ), - ); + ); - expect(result.data.result.ssl?.validation_records).toBeUndefined(); - }); + expect(result.data.result.ssl?.validation_records).toBeUndefined(); + }), + )); - test("accepts missing custom-hostname ownership verification", async () => { - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1GetHostnameConfig">(operationDefinitions.v1GetHostnameConfig, { - ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - jsonResponse(request, 200, { - status: "4_origin_setup_completed", - custom_hostname: "shop.acme.dev", - data: { - success: true, - errors: [], - messages: [], - result: { - id: "hostname-id", - hostname: "shop.acme.dev", - ssl: { + test("accepts missing custom-hostname ownership verification", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1GetHostnameConfig">(operationDefinitions.v1GetHostnameConfig, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + status: "4_origin_setup_completed", + custom_hostname: "shop.acme.dev", + data: { + success: true, + errors: [], + messages: [], + result: { + id: "hostname-id", + hostname: "shop.acme.dev", + ssl: { + status: "active", + }, + custom_origin_server: "abcdefghijklmnopqrst.supabase.co", status: "active", }, - custom_origin_server: "abcdefghijklmnopqrst.supabase.co", - status: "active", }, - }, - }), + }), + ), ), ), - ), - ), - ); + ); - expect(result.data.result.ownership_verification).toBeUndefined(); - expect(result.data.result.ssl?.validation_records).toBeUndefined(); - }); + expect(result.data.result.ownership_verification).toBeUndefined(); + expect(result.data.result.ssl?.validation_records).toBeUndefined(); + }), + )); - test("accepts processing custom-hostname responses without top-level status or hostname", async () => { - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1GetHostnameConfig">(operationDefinitions.v1GetHostnameConfig, { - ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - jsonResponse(request, 200, { - data: { - success: true, - errors: [], - messages: [], - result: { - id: "hostname-id", - hostname: "shop.acme.dev", - ssl: { - status: "initializing", - }, - ownership_verification: { - type: "txt", - name: "_cf-custom-hostname.shop.acme.dev", - value: "verification-token", + test("accepts processing custom-hostname responses without top-level status or hostname", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1GetHostnameConfig">(operationDefinitions.v1GetHostnameConfig, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + data: { + success: true, + errors: [], + messages: [], + result: { + id: "hostname-id", + hostname: "shop.acme.dev", + ssl: { + status: "initializing", + }, + ownership_verification: { + type: "txt", + name: "_cf-custom-hostname.shop.acme.dev", + value: "verification-token", + }, + custom_origin_server: "abcdefghijklmnopqrst.supabase.co", + status: "pending", }, - custom_origin_server: "abcdefghijklmnopqrst.supabase.co", - status: "pending", }, - }, - }), + }), + ), ), ), - ), - ), - ); - - expect(result.status).toBeUndefined(); - expect(result.custom_hostname).toBeUndefined(); - expect(result.data.result.ssl?.validation_records).toBeUndefined(); - }); - - test("does not retry 5xx responses for POST requests", async () => { - let attempts = 0; + ); - const exit = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { - db_pass: "hunter2", - name: "project-name", - organization_slug: "my-org", - }), - ), - Effect.exit, - Effect.provide( - httpClientLayer((request) => { - attempts += 1; - return Effect.succeed( - jsonResponse(request, 500, { - error: "do not retry post", - }), - ); - }), - ), - ), - ); + expect(result.status).toBeUndefined(); + expect(result.custom_hostname).toBeUndefined(); + expect(result.data.result.ssl?.validation_records).toBeUndefined(); + }), + )); - expect(attempts).toBe(1); - expect(Exit.isFailure(exit)).toBe(true); - }); + test("does not retry 5xx responses for POST requests", () => + Effect.runPromise( + Effect.gen(function* () { + let attempts = 0; + + const exit = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { + db_pass: "hunter2", + name: "project-name", + organization_slug: "my-org", + }), + ), + Effect.exit, + Effect.provide( + httpClientLayer((request) => { + attempts += 1; + return Effect.succeed( + jsonResponse(request, 500, { + error: "do not retry post", + }), + ); + }), + ), + ); - test("does not retry transport errors for POST requests", async () => { - let attempts = 0; + expect(attempts).toBe(1); + expect(Exit.isFailure(exit)).toBe(true); + }), + )); - const exit = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { - db_pass: "hunter2", - name: "project-name", - organization_slug: "my-org", - }), - ), - Effect.exit, - Effect.provide( - httpClientLayer((request) => { - attempts += 1; - return attempts === 1 - ? Effect.fail(transportError(request, "connection reset")) - : Effect.succeed( - jsonResponse(request, 409, { - message: "project already exists", - }), - ); - }), - ), - ), - ); + test("does not retry transport errors for POST requests", () => + Effect.runPromise( + Effect.gen(function* () { + let attempts = 0; + + const exit = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { + db_pass: "hunter2", + name: "project-name", + organization_slug: "my-org", + }), + ), + Effect.exit, + Effect.provide( + httpClientLayer((request) => { + attempts += 1; + return attempts === 1 + ? Effect.fail(transportError(request, "connection reset")) + : Effect.succeed( + jsonResponse(request, 409, { + message: "project already exists", + }), + ); + }), + ), + ); - expect(attempts).toBe(1); - expect(Exit.isFailure(exit)).toBe(true); - }); + expect(attempts).toBe(1); + expect(Exit.isFailure(exit)).toBe(true); + }), + )); - test("does not retry transport errors for PATCH requests", async () => { - let attempts = 0; + test("does not retry transport errors for PATCH requests", () => + Effect.runPromise( + Effect.gen(function* () { + let attempts = 0; - const exit = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1PatchAMigration">(operationDefinitions.v1PatchAMigration, { - ref: "abcdefghijklmnopqrst", - version: "20240101000000", - name: "renamed", - }), - ), - Effect.exit, - Effect.provide( - httpClientLayer((request) => { - attempts += 1; - return attempts === 1 - ? Effect.fail(transportError(request, "connection reset")) - : Effect.succeed( - HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })), - ); - }), - ), - ), - ); + const exit = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1PatchAMigration">(operationDefinitions.v1PatchAMigration, { + ref: "abcdefghijklmnopqrst", + version: "20240101000000", + name: "renamed", + }), + ), + Effect.exit, + Effect.provide( + httpClientLayer((request) => { + attempts += 1; + return attempts === 1 + ? Effect.fail(transportError(request, "connection reset")) + : Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })), + ); + }), + ), + ); - expect(attempts).toBe(1); - expect(Exit.isFailure(exit)).toBe(true); - }); + expect(attempts).toBe(1); + expect(Exit.isFailure(exit)).toBe(true); + }), + )); - test("stops after the configured number of transport retries", async () => { - const { requestTimes, exit } = await onVirtualClock( + test("stops after the configured number of transport retries", () => + Effect.runPromise( Effect.gen(function* () { - const call = yield* startVirtualCall( - listProjects, - (request) => transportError(request, "still broken"), - { retry: { maxRetries: 2 } }, + const { requestTimes, exit } = yield* onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall( + listProjects, + (request) => transportError(request, "still broken"), + { retry: { maxRetries: 2 } }, + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + }), ); - yield* TestClock.adjust(Duration.minutes(1)); - return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; - }), - ); - expect(requestTimes).toHaveLength(3); - expect(Exit.isFailure(exit)).toBe(true); - }); + expect(requestTimes).toHaveLength(3); + expect(Exit.isFailure(exit)).toBe(true); + }), + )); - test("decodes text responses through the unified execute path", async () => { - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1DiffABranch">(operationDefinitions.v1DiffABranch, { - // Must satisfy the oneOf union's project-ref/uuid pattern; a free-form - // string like "branch-ref" doesn't match either branch. - branch_id_or_ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response("select * from test;", { - status: 200, - headers: { - "content-type": "text/plain", - }, - }), + test("decodes text responses through the unified execute path", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1DiffABranch">(operationDefinitions.v1DiffABranch, { + // Must satisfy the oneOf union's project-ref/uuid pattern; a free-form + // string like "branch-ref" doesn't match either branch. + branch_id_or_ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("select * from test;", { + status: 200, + headers: { + "content-type": "text/plain", + }, + }), + ), ), ), ), - ), - ), - ); + ); - expect(result).toBe("select * from test;"); - }); + expect(result).toBe("select * from test;"); + }), + )); - test("decodes void responses through the unified execute path", async () => { - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1DisablePreviewBranching">( - operationDefinitions.v1DisablePreviewBranching, - { - ref: "abcdefghijklmnopqrst", - }, + test("decodes void responses through the unified execute path", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1DisablePreviewBranching">( + operationDefinitions.v1DisablePreviewBranching, + { + ref: "abcdefghijklmnopqrst", + }, + ), ), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(null, { - status: 204, - }), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { + status: 204, + }), + ), ), ), ), - ), - ), - ); + ); + + expect(result).toBeUndefined(); + }), + )); + + test("serializes oauth token exchange bodies as x-www-form-urlencoded", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1ExchangeOauthToken">(operationDefinitions.v1ExchangeOauthToken, { + body: { + grant_type: "authorization_code", + client_id: "11111111-1111-4111-8111-111111111111", + client_secret: "client-secret", + code: "auth-code", + code_verifier: "code-verifier", + redirect_uri: "https://example.com/callback", + resource: "https://mcp.supabase.com", + }, + }), + ), + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed(oauthTokenResponse(request)); + }), + ), + ); - expect(result).toBeUndefined(); - }); + expect(result.access_token).toBe("access-token"); + expect(seenRequest).toBeDefined(); + expect(seenRequest?.headers["content-type"]).toBe("application/x-www-form-urlencoded"); + + const url = requestUrl(seenRequest!); + expect(url.pathname).toBe("/v1/oauth/token"); + expect(Array.from(url.searchParams.keys())).toEqual([]); + + const body = new URLSearchParams(requestBodyText(seenRequest!)); + expect(body.get("grant_type")).toBe("authorization_code"); + expect(body.get("client_id")).toBe("11111111-1111-4111-8111-111111111111"); + expect(body.get("client_secret")).toBe("client-secret"); + expect(body.get("code")).toBe("auth-code"); + expect(body.get("code_verifier")).toBe("code-verifier"); + expect(body.get("redirect_uri")).toBe("https://example.com/callback"); + expect(body.get("resource")).toBe("https://mcp.supabase.com"); + expect(body.has("refresh_token")).toBe(false); + expect(body.has("scope")).toBe(false); + }), + )); - test("serializes oauth token exchange bodies as x-www-form-urlencoded", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1ExchangeOauthToken">(operationDefinitions.v1ExchangeOauthToken, { - body: { - grant_type: "authorization_code", - client_id: "11111111-1111-4111-8111-111111111111", - client_secret: "client-secret", - code: "auth-code", - code_verifier: "code-verifier", - redirect_uri: "https://example.com/callback", - resource: "https://mcp.supabase.com", - }, - }), - ), - Effect.provide( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed(oauthTokenResponse(request)); - }), - ), - ), - ); + test("serializes refresh-token exchange bodies without omitted oauth fields", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1ExchangeOauthToken">(operationDefinitions.v1ExchangeOauthToken, { + body: { + grant_type: "refresh_token", + refresh_token: "refresh-token", + scope: "read:projects", + }, + }), + ), + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed(oauthTokenResponse(request)); + }), + ), + ); - expect(result.access_token).toBe("access-token"); - expect(seenRequest).toBeDefined(); - expect(seenRequest?.headers["content-type"]).toBe("application/x-www-form-urlencoded"); - - const url = requestUrl(seenRequest!); - expect(url.pathname).toBe("/v1/oauth/token"); - expect(Array.from(url.searchParams.keys())).toEqual([]); - - const body = new URLSearchParams(requestBodyText(seenRequest!)); - expect(body.get("grant_type")).toBe("authorization_code"); - expect(body.get("client_id")).toBe("11111111-1111-4111-8111-111111111111"); - expect(body.get("client_secret")).toBe("client-secret"); - expect(body.get("code")).toBe("auth-code"); - expect(body.get("code_verifier")).toBe("code-verifier"); - expect(body.get("redirect_uri")).toBe("https://example.com/callback"); - expect(body.get("resource")).toBe("https://mcp.supabase.com"); - expect(body.has("refresh_token")).toBe(false); - expect(body.has("scope")).toBe(false); - }); + expect(result.refresh_token).toBe("refresh-token"); - test("serializes refresh-token exchange bodies without omitted oauth fields", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1ExchangeOauthToken">(operationDefinitions.v1ExchangeOauthToken, { - body: { - grant_type: "refresh_token", - refresh_token: "refresh-token", - scope: "read:projects", - }, - }), - ), - Effect.provide( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed(oauthTokenResponse(request)); - }), - ), - ), - ); + const body = new URLSearchParams(requestBodyText(seenRequest!)); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("refresh-token"); + expect(body.get("scope")).toBe("read:projects"); + expect(body.has("code")).toBe(false); + expect(body.has("client_id")).toBe(false); + }), + )); - expect(result.refresh_token).toBe("refresh-token"); + test("serializes create function requests as eszip bodies with metadata query params", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + const body = new TextEncoder().encode("console.log('deploy create');"); - const body = new URLSearchParams(requestBodyText(seenRequest!)); - expect(body.get("grant_type")).toBe("refresh_token"); - expect(body.get("refresh_token")).toBe("refresh-token"); - expect(body.get("scope")).toBe("read:projects"); - expect(body.has("code")).toBe(false); - expect(body.has("client_id")).toBe(false); - }); + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1CreateAFunction">(operationDefinitions.v1CreateAFunction, { + ref: "abcdefghijklmnopqrst", + slug: "demo", + name: "Demo Function", + verify_jwt: true, + entrypoint_path: "functions/demo/index.ts", + import_map_path: "functions/demo/deno.json", + ezbr_sha256: "abc123", + body, + }), + ), + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed(functionResponse(request, 201)); + }), + ), + ); - test("serializes create function requests as eszip bodies with metadata query params", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - const body = new TextEncoder().encode("console.log('deploy create');"); - - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1CreateAFunction">(operationDefinitions.v1CreateAFunction, { - ref: "abcdefghijklmnopqrst", - slug: "demo", - name: "Demo Function", - verify_jwt: true, - entrypoint_path: "functions/demo/index.ts", - import_map_path: "functions/demo/deno.json", - ezbr_sha256: "abc123", - body, - }), - ), - Effect.provide( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed(functionResponse(request, 201)); - }), - ), - ), - ); + expect(result.slug).toBe("demo"); + expect(seenRequest).toBeDefined(); + expect(seenRequest?.headers["content-type"]).toBe("application/vnd.denoland.eszip"); + expect(requestBodyBytes(seenRequest!)).toEqual(body); + + const url = requestUrl(seenRequest!); + expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions"); + expect(requestUrlParam(seenRequest!, "slug")).toBe("demo"); + expect(requestUrlParam(seenRequest!, "name")).toBe("Demo Function"); + expect(requestUrlParam(seenRequest!, "verify_jwt")).toBe("true"); + expect(requestUrlParam(seenRequest!, "entrypoint_path")).toBe("functions/demo/index.ts"); + expect(requestUrlParam(seenRequest!, "import_map_path")).toBe("functions/demo/deno.json"); + expect(requestUrlParam(seenRequest!, "ezbr_sha256")).toBe("abc123"); + }), + )); - expect(result.slug).toBe("demo"); - expect(seenRequest).toBeDefined(); - expect(seenRequest?.headers["content-type"]).toBe("application/vnd.denoland.eszip"); - expect(requestBodyBytes(seenRequest!)).toEqual(body); - - const url = requestUrl(seenRequest!); - expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions"); - expect(requestUrlParam(seenRequest!, "slug")).toBe("demo"); - expect(requestUrlParam(seenRequest!, "name")).toBe("Demo Function"); - expect(requestUrlParam(seenRequest!, "verify_jwt")).toBe("true"); - expect(requestUrlParam(seenRequest!, "entrypoint_path")).toBe("functions/demo/index.ts"); - expect(requestUrlParam(seenRequest!, "import_map_path")).toBe("functions/demo/deno.json"); - expect(requestUrlParam(seenRequest!, "ezbr_sha256")).toBe("abc123"); - }); + test("serializes update function requests as eszip bodies with metadata query params", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + const body = new TextEncoder().encode("console.log('deploy update');").buffer; - test("serializes update function requests as eszip bodies with metadata query params", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - const body = new TextEncoder().encode("console.log('deploy update');").buffer; - - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1UpdateAFunction">(operationDefinitions.v1UpdateAFunction, { - ref: "abcdefghijklmnopqrst", - function_slug: "demo", - slug: "demo-renamed", - verify_jwt: true, - entrypoint_path: "functions/demo/index.ts", - import_map_path: "functions/demo/deno.json", - ezbr_sha256: "def456", - body, - }), - ), - Effect.provide( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed(functionResponse(request, 200)); - }), - ), - ), - ); + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1UpdateAFunction">(operationDefinitions.v1UpdateAFunction, { + ref: "abcdefghijklmnopqrst", + function_slug: "demo", + slug: "demo-renamed", + verify_jwt: true, + entrypoint_path: "functions/demo/index.ts", + import_map_path: "functions/demo/deno.json", + ezbr_sha256: "def456", + body, + }), + ), + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed(functionResponse(request, 200)); + }), + ), + ); - expect(result.slug).toBe("demo"); - expect(seenRequest).toBeDefined(); - expect(seenRequest?.headers["content-type"]).toBe("application/vnd.denoland.eszip"); - expect(requestBodyBytes(seenRequest!)).toEqual(new Uint8Array(body)); - - const url = requestUrl(seenRequest!); - expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions/demo"); - expect(requestUrlParam(seenRequest!, "slug")).toBe("demo-renamed"); - expect(requestUrlParam(seenRequest!, "verify_jwt")).toBe("true"); - expect(requestUrlParam(seenRequest!, "entrypoint_path")).toBe("functions/demo/index.ts"); - expect(requestUrlParam(seenRequest!, "import_map_path")).toBe("functions/demo/deno.json"); - expect(requestUrlParam(seenRequest!, "ezbr_sha256")).toBe("def456"); - }); + expect(result.slug).toBe("demo"); + expect(seenRequest).toBeDefined(); + expect(seenRequest?.headers["content-type"]).toBe("application/vnd.denoland.eszip"); + expect(requestBodyBytes(seenRequest!)).toEqual(new Uint8Array(body)); + + const url = requestUrl(seenRequest!); + expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions/demo"); + expect(requestUrlParam(seenRequest!, "slug")).toBe("demo-renamed"); + expect(requestUrlParam(seenRequest!, "verify_jwt")).toBe("true"); + expect(requestUrlParam(seenRequest!, "entrypoint_path")).toBe("functions/demo/index.ts"); + expect(requestUrlParam(seenRequest!, "import_map_path")).toBe("functions/demo/deno.json"); + expect(requestUrlParam(seenRequest!, "ezbr_sha256")).toBe("def456"); + }), + )); - test("serializes deploy function requests as multipart bodies with json metadata", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - - const metadata = { - entrypoint_path: "functions/demo/index.ts", - import_map_path: "functions/demo/deno.json", - static_patterns: ["functions/demo/static/**/*.js"], - verify_jwt: true, - name: "demo", - } as const; - - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1DeployAFunction">(operationDefinitions.v1DeployAFunction, { - ref: "abcdefghijklmnopqrst", - slug: "demo", - bundleOnly: true, - body: { - metadata, - file: [new Uint8Array([1, 2, 3]), new Blob(["deno-config"])], - }, - }), - ), - Effect.provide( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed(deployFunctionResponse(request)); - }), - ), - ), - ); + test("serializes deploy function requests as multipart bodies with json metadata", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + + const metadata = { + entrypoint_path: "functions/demo/index.ts", + import_map_path: "functions/demo/deno.json", + static_patterns: ["functions/demo/static/**/*.js"], + verify_jwt: true, + name: "demo", + } as const; + + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v1DeployAFunction">(operationDefinitions.v1DeployAFunction, { + ref: "abcdefghijklmnopqrst", + slug: "demo", + bundleOnly: true, + body: { + metadata, + file: [new Uint8Array([1, 2, 3]), new Blob(["deno-config"])], + }, + }), + ), + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed(deployFunctionResponse(request)); + }), + ), + ); - expect(result.slug).toBe("demo"); - expect(seenRequest).toBeDefined(); + expect(result.slug).toBe("demo"); + expect(seenRequest).toBeDefined(); - const url = requestUrl(seenRequest!); - expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions/deploy"); - expect(requestUrlParam(seenRequest!, "slug")).toBe("demo"); - expect(requestUrlParam(seenRequest!, "bundleOnly")).toBe("true"); + const url = requestUrl(seenRequest!); + expect(url.pathname).toBe("/v1/projects/abcdefghijklmnopqrst/functions/deploy"); + expect(requestUrlParam(seenRequest!, "slug")).toBe("demo"); + expect(requestUrlParam(seenRequest!, "bundleOnly")).toBe("true"); - const formData = requestFormData(seenRequest!); - expect(JSON.parse(formDataTextValue(formData, "metadata"))).toEqual(metadata); - expect(await formDataFileTexts(formData, "file")).toEqual([ - "\u0001\u0002\u0003", - "deno-config", - ]); - }); + const formData = requestFormData(seenRequest!); + expect( + yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + formDataTextValue(formData, "metadata"), + ), + ).toEqual(metadata); + expect(yield* formDataFileTexts(formData, "file")).toEqual([ + "\u0001\u0002\u0003", + "deno-config", + ]); + }), + )); test("rejects string raw binary bodies at schema decode time", () => { expect(() => @@ -1243,176 +1270,175 @@ describe("makeSupabaseApiClient", () => { ).toThrow(); }); - test("surfaces a 404 on a v2 operation as a distinguishable status error and wires the request identically to v1", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - - const client = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.provide( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed( - jsonResponse(request, 404, { message: "Organization not found" }), - ); - }), - ), - ), - ); + test("surfaces a 404 on a v2 operation as a distinguishable status error and wires the request identically to v1", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + + const client = yield* makeSupabaseApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed( + jsonResponse(request, 404, { message: "Organization not found" }), + ); + }), + ), + ); - const error = await Effect.runPromise( - client - .execute(operationDefinitions.v2ListOrganizationMembers, { slug: "my-org" }) - .pipe(Effect.flip), - ); + const error = yield* client + .execute(operationDefinitions.v2ListOrganizationMembers, { slug: "my-org" }) + .pipe(Effect.flip); - expect(HttpClientError.isHttpClientError(error)).toBe(true); - if (!HttpClientError.isHttpClientError(error)) { - throw new Error("expected HttpClientError"); - } - expect(error.reason._tag).toBe("StatusCodeError"); - if (error.reason._tag !== "StatusCodeError") { - throw new Error("expected StatusCodeError"); - } - expect(error.reason.response.status).toBe(404); + expect(HttpClientError.isHttpClientError(error)).toBe(true); + if (!HttpClientError.isHttpClientError(error)) { + throw new Error("expected HttpClientError"); + } + expect(error.reason._tag).toBe("StatusCodeError"); + if (error.reason._tag !== "StatusCodeError") { + throw new Error("expected StatusCodeError"); + } + expect(error.reason.response.status).toBe(404); - expect(seenRequest).toBeDefined(); - expect(seenRequest?.url).toBe("https://api.supabase.com/v2/organizations/my-org/members"); - expect(seenRequest?.headers.authorization).toBe("Bearer test-token"); - }); + expect(seenRequest).toBeDefined(); + expect(seenRequest?.url).toBe("https://api.supabase.com/v2/organizations/my-org/members"); + expect(seenRequest?.headers.authorization).toBe("Bearer test-token"); + }), + )); - test("decodes a nested v2GetProjectConfig payload through the unified execute path", async () => { - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v2GetProjectConfig">(operationDefinitions.v2GetProjectConfig, { - ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => - Effect.succeed( - jsonResponse(request, 200, { - data: { - type: "project_config", - id: "abcdefghijklmnopqrst", - attributes: { - database: { - major_version: 17, - ssl_enforced: true, - network_restrictions: { - entitlement: "disallowed", - status: "stored", - allowed_cidrs: [], - }, - postgres_settings: {}, - }, - pooler: { - pool_mode: "transaction", - ignore_startup_parameters: "", - server_idle_timeout: 0, - server_lifetime: 0, - query_wait_timeout: 0, - reserve_pool_size: 0, - default_pool_size: 0, - max_client_conn: 0, - }, - auth: {}, - api: { - db_schema: "public", - db_extra_search_path: "", - max_rows: 1000, - db_pool_acquisition_timeout: 0, - db_pool: null, - }, - realtime: { - private_only: false, - max_concurrent_users: 0, - max_events_per_second: 0, - max_bytes_per_second: 0, - max_channels_per_client: 0, - max_joins_per_second: 0, - max_presence_events_per_second: 0, - max_payload_size_in_kb: 0, - presence_enabled: true, - suspend: false, - connection_pool: 0, - postgres_changes_pool: null, - }, - storage: { - file_size_limit: 0, - features: { - image_transformation: { enabled: true }, - s3_protocol: { enabled: true }, - purge_cache: { enabled: true }, - iceberg_catalog: { - enabled: false, - max_namespaces: 0, - max_tables: 0, - max_catalogs: 0, + test("decodes a nested v2GetProjectConfig payload through the unified execute path", () => + Effect.runPromise( + Effect.gen(function* () { + const result = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.execute<"v2GetProjectConfig">(operationDefinitions.v2GetProjectConfig, { + ref: "abcdefghijklmnopqrst", + }), + ), + Effect.provide( + httpClientLayer((request) => + Effect.succeed( + jsonResponse(request, 200, { + data: { + type: "project_config", + id: "abcdefghijklmnopqrst", + attributes: { + database: { + major_version: 17, + ssl_enforced: true, + network_restrictions: { + entitlement: "disallowed", + status: "stored", + allowed_cidrs: [], }, - vector_buckets: { enabled: false, max_buckets: 0, max_indexes: 0 }, + postgres_settings: {}, + }, + pooler: { + pool_mode: "transaction", + ignore_startup_parameters: "", + server_idle_timeout: 0, + server_lifetime: 0, + query_wait_timeout: 0, + reserve_pool_size: 0, + default_pool_size: 0, + max_client_conn: 0, }, - capabilities: { - list_v2: true, - iceberg_catalog: true, - object_versioning: true, + auth: {}, + api: { + db_schema: "public", + db_extra_search_path: "", + max_rows: 1000, + db_pool_acquisition_timeout: 0, + db_pool: null, + }, + realtime: { + private_only: false, + max_concurrent_users: 0, + max_events_per_second: 0, + max_bytes_per_second: 0, + max_channels_per_client: 0, + max_joins_per_second: 0, + max_presence_events_per_second: 0, + max_payload_size_in_kb: 0, + presence_enabled: true, + suspend: false, + connection_pool: 0, + postgres_changes_pool: null, + }, + storage: { + file_size_limit: 0, + features: { + image_transformation: { enabled: true }, + s3_protocol: { enabled: true }, + purge_cache: { enabled: true }, + iceberg_catalog: { + enabled: false, + max_namespaces: 0, + max_tables: 0, + max_catalogs: 0, + }, + vector_buckets: { enabled: false, max_buckets: 0, max_indexes: 0 }, + }, + capabilities: { + list_v2: true, + iceberg_catalog: true, + object_versioning: true, + }, + upstream_target: "main", + migration_version: "1", }, - upstream_target: "main", - migration_version: "1", }, }, - }, - }), + }), + ), ), ), - ), - ), - ); + ); - expect(result.data.attributes.database.network_restrictions.entitlement).toBe("disallowed"); - expect(result.data.attributes.database.major_version).toBe(17); - expect(result.data.attributes.storage.upstream_target).toBe("main"); - expect(result.data.attributes.api.db_pool).toBeNull(); - }); + expect(result.data.attributes.database.network_restrictions.entitlement).toBe("disallowed"); + expect(result.data.attributes.database.major_version).toBe(17); + expect(result.data.attributes.storage.upstream_target).toBe("main"); + expect(result.data.attributes.api.db_pool).toBeNull(); + }), + )); // `style: deepObject` — every object-valued query parameter in the spec — is // one `param[key]=value` pair per entry. Serialized as a JSON blob instead, // the server reads no page size and no cursor, so a paginated walk silently // returns the first default-sized page forever. - test("expands deepObject query parameters into one pair per entry", async () => { - let seenRequest: HttpClientRequest.HttpClientRequest | undefined; - - const client = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.provide( - httpClientLayer((request) => { - seenRequest = request; - return Effect.succeed( - jsonResponse(request, 200, { - data: [], - links: { first: null, last: null, prev: null, next: null }, - }), - ); - }), - ), - ), - ); + test("expands deepObject query parameters into one pair per entry", () => + Effect.runPromise( + Effect.gen(function* () { + let seenRequest: HttpClientRequest.HttpClientRequest | undefined; + + const client = yield* makeSupabaseApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + seenRequest = request; + return Effect.succeed( + jsonResponse(request, 200, { + data: [], + links: { first: null, last: null, prev: null, next: null }, + }), + ); + }), + ), + ); - await Effect.runPromise( - client.execute(operationDefinitions.v2ListNotebooks, { - ref: "abcdefghijklmnopqrst", - page: { size: 100, after: "cursor-1" }, - filter: { name: "sales" }, + yield* client.execute(operationDefinitions.v2ListNotebooks, { + ref: "abcdefghijklmnopqrst", + page: { size: 100, after: "cursor-1" }, + filter: { name: "sales" }, + }); + + expect(seenRequest).toBeDefined(); + expect(requestUrlParam(seenRequest!, "page[size]")).toBe("100"); + expect(requestUrlParam(seenRequest!, "page[after]")).toBe("cursor-1"); + expect(requestUrlParam(seenRequest!, "filter[name]")).toBe("sales"); + // The un-expanded names never reach the wire. + expect(requestUrlParam(seenRequest!, "page")).toBeUndefined(); + expect(requestUrlParam(seenRequest!, "filter")).toBeUndefined(); }), - ); - - expect(seenRequest).toBeDefined(); - expect(requestUrlParam(seenRequest!, "page[size]")).toBe("100"); - expect(requestUrlParam(seenRequest!, "page[after]")).toBe("cursor-1"); - expect(requestUrlParam(seenRequest!, "filter[name]")).toBe("sales"); - // The un-expanded names never reach the wire. - expect(requestUrlParam(seenRequest!, "page")).toBeUndefined(); - expect(requestUrlParam(seenRequest!, "filter")).toBeUndefined(); - }); + )); }); describe("retry policy", () => { @@ -1437,180 +1463,200 @@ describe("retry policy", () => { const gaps = (times: ReadonlyArray) => times.slice(1).map((time, i) => time - times[i]!); - test.each([500, 503])( - "waits out the backoff before retrying a GET after a %i", - async (status) => { - const { early, afterBackoff, exit } = await onVirtualClock( - Effect.gen(function* () { - const call = yield* startVirtualCall(listProjects, (request, attempt) => - attempt === 1 ? jsonResponse(request, status, {}) : jsonResponse(request, 200, []), - ); - // The first backoff ceiling is 250ms, so a jitter draw of 0.5 waits 125ms. - yield* TestClock.adjust(Duration.millis(124)); - const early = call.requestTimes.length; - yield* TestClock.adjust(Duration.millis(1)); - const afterBackoff = call.requestTimes.length; - return { early, afterBackoff, exit: yield* Fiber.join(call.fiber) }; - }), - ); + test.each([500, 503])("waits out the backoff before retrying a GET after a %i", (status) => + Effect.runPromise( + Effect.gen(function* () { + const { early, afterBackoff, exit } = yield* onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt === 1 ? jsonResponse(request, status, {}) : jsonResponse(request, 200, []), + ); + // The first backoff ceiling is 250ms, so a jitter draw of 0.5 waits 125ms. + yield* TestClock.adjust(Duration.millis(124)); + const early = call.requestTimes.length; + yield* TestClock.adjust(Duration.millis(1)); + const afterBackoff = call.requestTimes.length; + return { early, afterBackoff, exit: yield* Fiber.join(call.fiber) }; + }), + ); - expect(early).toBe(1); - expect(afterBackoff).toBe(2); - expect(exit).toEqual(Exit.succeed([])); - }, + expect(early).toBe(1); + expect(afterBackoff).toBe(2); + expect(exit).toEqual(Exit.succeed([])); + }), + ), ); - test("backs off exponentially up to the delay cap", async () => { - const requestTimes = await onVirtualClock( + test("backs off exponentially up to the delay cap", () => + Effect.runPromise( Effect.gen(function* () { - const call = yield* startVirtualCall( - listProjects, - (request) => jsonResponse(request, 503, {}), - { retry: { maxRetries: 8 } }, + const requestTimes = yield* onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall( + listProjects, + (request) => jsonResponse(request, 503, {}), + { retry: { maxRetries: 8 } }, + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), + 1, ); - yield* TestClock.adjust(Duration.minutes(1)); - yield* Fiber.join(call.fiber); - return call.requestTimes; - }), - 1, - ); - expect(gaps(requestTimes)).toEqual([250, 500, 1000, 2000, 4000, 4000, 4000, 4000]); - }); + expect(gaps(requestTimes)).toEqual([250, 500, 1000, 2000, 4000, 4000, 4000, 4000]); + }), + )); - test("retries a POST after a 429 only once its Retry-After seconds have elapsed", async () => { - const { early, afterWait, exit } = await onVirtualClock( + test("retries a POST after a 429 only once its Retry-After seconds have elapsed", () => + Effect.runPromise( Effect.gen(function* () { - const call = yield* startVirtualCall(createProject, (request, attempt) => - attempt === 1 - ? HttpClientResponse.fromWeb( - request, - new Response("{}", { status: 429, headers: { "retry-after": "2" } }), - ) - : createdProject(request), + const { early, afterWait, exit } = yield* onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(createProject, (request, attempt) => + attempt === 1 + ? HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": "2" } }), + ) + : createdProject(request), + ); + yield* TestClock.adjust(Duration.millis(1999)); + const early = call.requestTimes.length; + yield* TestClock.adjust(Duration.millis(1)); + const afterWait = call.requestTimes.length; + return { early, afterWait, exit: yield* Fiber.join(call.fiber) }; + }), ); - yield* TestClock.adjust(Duration.millis(1999)); - const early = call.requestTimes.length; - yield* TestClock.adjust(Duration.millis(1)); - const afterWait = call.requestTimes.length; - return { early, afterWait, exit: yield* Fiber.join(call.fiber) }; - }), - ); - expect(early).toBe(1); - expect(afterWait).toBe(2); - expect(Exit.isSuccess(exit)).toBe(true); - }); + expect(early).toBe(1); + expect(afterWait).toBe(2); + expect(Exit.isSuccess(exit)).toBe(true); + }), + )); - test("retries a GET immediately after a 500 with Retry-After 0", async () => { - const requestTimes = await onVirtualClock( + test("retries a GET immediately after a 500 with Retry-After 0", () => + Effect.runPromise( Effect.gen(function* () { - const call = yield* startVirtualCall(listProjects, (request, attempt) => - attempt === 1 - ? HttpClientResponse.fromWeb( - request, - new Response("{}", { status: 500, headers: { "retry-after": "0" } }), - ) - : jsonResponse(request, 200, []), + const requestTimes = yield* onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt === 1 + ? HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 500, headers: { "retry-after": "0" } }), + ) + : jsonResponse(request, 200, []), + ); + yield* TestClock.adjust(Duration.millis(0)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), ); - yield* TestClock.adjust(Duration.millis(0)); - yield* Fiber.join(call.fiber); - return call.requestTimes; - }), - ); - expect(gaps(requestTimes)).toEqual([0]); - }); + expect(gaps(requestTimes)).toEqual([0]); + }), + )); - test("honours an HTTP-date Retry-After on a 503", async () => { - const now = Date.parse("2026-10-13T12:00:00Z"); - const requestTimes = await onVirtualClock( + test("honours an HTTP-date Retry-After on a 503", () => + Effect.runPromise( Effect.gen(function* () { - yield* TestClock.setTime(now); - const call = yield* startVirtualCall(listProjects, (request, attempt) => - attempt === 1 - ? HttpClientResponse.fromWeb( - request, - new Response("{}", { - status: 503, - headers: { "retry-after": "Tue, 13 Oct 2026 12:00:03 GMT" }, - }), - ) - : jsonResponse(request, 200, []), + const now = Date.parse("2026-10-13T12:00:00Z"); + const requestTimes = yield* onVirtualClock( + Effect.gen(function* () { + yield* TestClock.setTime(now); + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt === 1 + ? HttpClientResponse.fromWeb( + request, + new Response("{}", { + status: 503, + headers: { "retry-after": "Tue, 13 Oct 2026 12:00:03 GMT" }, + }), + ) + : jsonResponse(request, 200, []), + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), ); - yield* TestClock.adjust(Duration.minutes(1)); - yield* Fiber.join(call.fiber); - return call.requestTimes; - }), - ); - expect(gaps(requestTimes)).toEqual([3000]); - }); + expect(gaps(requestTimes)).toEqual([3000]); + }), + )); - test.each(["soon", "-5", ""])( - "falls back to the backoff when Retry-After is %j", - async (retryAfter) => { - const requestTimes = await onVirtualClock( - Effect.gen(function* () { - const call = yield* startVirtualCall(listProjects, (request, attempt) => - attempt === 1 - ? HttpClientResponse.fromWeb( - request, - new Response("{}", { status: 429, headers: { "retry-after": retryAfter } }), - ) - : jsonResponse(request, 200, []), - ); - yield* TestClock.adjust(Duration.minutes(1)); - yield* Fiber.join(call.fiber); - return call.requestTimes; - }), - ); + test.each(["soon", "-5", ""])("falls back to the backoff when Retry-After is %j", (retryAfter) => + Effect.runPromise( + Effect.gen(function* () { + const requestTimes = yield* onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt === 1 + ? HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": retryAfter } }), + ) + : jsonResponse(request, 200, []), + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), + ); - expect(gaps(requestTimes)).toEqual([125]); - }, + expect(gaps(requestTimes)).toEqual([125]); + }), + ), ); - test("returns a 429 whose Retry-After exceeds the cap without retrying", async () => { - const { requestTimes, exit } = await onVirtualClock( + test("returns a 429 whose Retry-After exceeds the cap without retrying", () => + Effect.runPromise( Effect.gen(function* () { - const call = yield* startVirtualCall(listProjects, (request) => - HttpClientResponse.fromWeb( - request, - new Response("{}", { status: 429, headers: { "retry-after": "30" } }), - ), + const { requestTimes, exit } = yield* onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request) => + HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": "30" } }), + ), + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + }), ); - yield* TestClock.adjust(Duration.minutes(1)); - return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; - }), - ); - expect(requestTimes).toHaveLength(1); - const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; - if (!HttpClientError.isHttpClientError(error) || error.reason._tag !== "StatusCodeError") { - throw new Error("expected StatusCodeError"); - } - expect(error.reason.response.status).toBe(429); - }); + expect(requestTimes).toHaveLength(1); + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + if (!HttpClientError.isHttpClientError(error) || error.reason._tag !== "StatusCodeError") { + throw new Error("expected StatusCodeError"); + } + expect(error.reason.response.status).toBe(429); + }), + )); - test("honours a Retry-After exactly at the cap", async () => { - const requestTimes = await onVirtualClock( + test("honours a Retry-After exactly at the cap", () => + Effect.runPromise( Effect.gen(function* () { - const call = yield* startVirtualCall(listProjects, (request, attempt) => - attempt === 1 - ? HttpClientResponse.fromWeb( - request, - new Response("{}", { status: 429, headers: { "retry-after": "10" } }), - ) - : jsonResponse(request, 200, []), + const requestTimes = yield* onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt === 1 + ? HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": "10" } }), + ) + : jsonResponse(request, 200, []), + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), ); - yield* TestClock.adjust(Duration.minutes(1)); - yield* Fiber.join(call.fiber); - return call.requestTimes; - }), - ); - expect(gaps(requestTimes)).toEqual([10_000]); - }); + expect(gaps(requestTimes)).toEqual([10_000]); + }), + )); describe("per-request retry options", () => { const listProjectsWith = (retry: SupabaseApiRetryOptions) => (client: SupabaseApiClientShape) => @@ -1624,120 +1670,158 @@ describe("retry policy", () => { return error.reason.response.status; }; - test("maxRetries 0 makes a single attempt and returns the 503", async () => { - const { requestTimes, exit } = await onVirtualClock( + test("maxRetries 0 makes a single attempt and returns the 503", () => + Effect.runPromise( Effect.gen(function* () { - const call = yield* startVirtualCall(listProjectsWith({ maxRetries: 0 }), (request) => - jsonResponse(request, 503, {}), + const { requestTimes, exit } = yield* onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjectsWith({ maxRetries: 0 }), (request) => + jsonResponse(request, 503, {}), + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + }), ); - yield* TestClock.adjust(Duration.minutes(1)); - return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; - }), - ); - expect(requestTimes).toHaveLength(1); - expect(failedStatus(exit)).toBe(503); - }); + expect(requestTimes).toHaveLength(1); + expect(failedStatus(exit)).toBe(503); + }), + )); - test("wins over the client-level retry options, field by field", async () => { - const { requestTimes, exit } = await onVirtualClock( + test("wins over the client-level retry options, field by field", () => + Effect.runPromise( Effect.gen(function* () { - const call = yield* startVirtualCall( - listProjectsWith({ maxRetries: 1 }), - (request) => jsonResponse(request, 503, {}), - { retry: { maxRetries: 4, requestTimeoutMs: 1000 } }, + const { requestTimes, exit } = yield* onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall( + listProjectsWith({ maxRetries: 1 }), + (request) => jsonResponse(request, 503, {}), + { retry: { maxRetries: 4, requestTimeoutMs: 1000 } }, + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + }), ); - yield* TestClock.adjust(Duration.minutes(1)); - return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; - }), - ); - expect(requestTimes).toHaveLength(2); - expect(failedStatus(exit)).toBe(503); - }); + expect(requestTimes).toHaveLength(2); + expect(failedStatus(exit)).toBe(503); + }), + )); - test("still applies when transformClient wraps the client", async () => { - const { requestTimes } = await onVirtualClock( + test("still applies when transformClient wraps the client", () => + Effect.runPromise( Effect.gen(function* () { - const call = yield* startVirtualCall( - listProjectsWith({ maxRetries: 0 }), - (request) => jsonResponse(request, 503, {}), - { - transformClient: (client) => Effect.succeed(HttpClient.mapRequest(client, (r) => r)), - }, + const { requestTimes } = yield* onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall( + listProjectsWith({ maxRetries: 0 }), + (request) => jsonResponse(request, 503, {}), + { + transformClient: (client) => + Effect.succeed(HttpClient.mapRequest(client, (r) => r)), + }, + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return { requestTimes: call.requestTimes }; + }), ); - yield* TestClock.adjust(Duration.minutes(1)); - yield* Fiber.join(call.fiber); - return { requestTimes: call.requestTimes }; - }), - ); - expect(requestTimes).toHaveLength(1); - }); + expect(requestTimes).toHaveLength(1); + }), + )); - test("requestTimeoutMs Infinity lets an attempt outlast the default timeout", async () => { - const { attempts, exit } = await onVirtualClock( + test("requestTimeoutMs Infinity lets an attempt outlast the default timeout", () => + Effect.runPromise( Effect.gen(function* () { - let attempts = 0; - const fiber = yield* makeSupabaseApiClient(config).pipe( - Effect.flatMap(listProjectsWith({ requestTimeoutMs: Number.POSITIVE_INFINITY })), - Effect.exit, - Effect.provide( - httpClientLayer((request) => - Effect.suspend(() => { - attempts += 1; - return Effect.as( - Effect.sleep(Duration.minutes(5)), - jsonResponse(request, 200, []), - ); - }), - ), - ), - Effect.forkChild, + const { attempts, exit } = yield* onVirtualClock( + Effect.gen(function* () { + let attempts = 0; + const fiber = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap(listProjectsWith({ requestTimeoutMs: Number.POSITIVE_INFINITY })), + Effect.exit, + Effect.provide( + httpClientLayer((request) => + Effect.suspend(() => { + attempts += 1; + return Effect.as( + Effect.sleep(Duration.minutes(5)), + jsonResponse(request, 200, []), + ); + }), + ), + ), + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.minutes(5)); + return { attempts, exit: yield* Fiber.join(fiber) }; + }), ); - yield* Effect.yieldNow; - yield* TestClock.adjust(Duration.minutes(5)); - return { attempts, exit: yield* Fiber.join(fiber) }; - }), - ); - expect(attempts).toBe(1); - expect(exit).toEqual(Exit.succeed([])); - }); + expect(attempts).toBe(1); + expect(exit).toEqual(Exit.succeed([])); + }), + )); }); - test("does not retry a POST after a 500 however long it waits", async () => { - const requestTimes = await onVirtualClock( + test("does not retry a POST after a 500 however long it waits", () => + Effect.runPromise( Effect.gen(function* () { - const call = yield* startVirtualCall(createProject, (request) => - jsonResponse(request, 500, {}), + const requestTimes = yield* onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(createProject, (request) => + jsonResponse(request, 500, {}), + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), ); - yield* TestClock.adjust(Duration.minutes(1)); - yield* Fiber.join(call.fiber); - return call.requestTimes; - }), - ); - expect(requestTimes).toHaveLength(1); - }); + expect(requestTimes).toHaveLength(1); + }), + )); - test("surfaces the last 503 once a GET exhausts its retries", async () => { - const { requestTimes, exit } = await onVirtualClock( + test("surfaces the last 503 once a GET exhausts its retries", () => + Effect.runPromise( Effect.gen(function* () { - const call = yield* startVirtualCall(listProjects, (request) => - jsonResponse(request, 503, { message: "unavailable" }), + const { requestTimes, exit } = yield* onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request) => + jsonResponse(request, 503, { message: "unavailable" }), + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + }), ); - yield* TestClock.adjust(Duration.minutes(1)); - return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + + expect(requestTimes).toHaveLength(6); + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + expect(HttpClientError.isHttpClientError(error)).toBe(true); + if (!HttpClientError.isHttpClientError(error) || error.reason._tag !== "StatusCodeError") { + throw new Error("expected StatusCodeError"); + } + expect(error.reason.response.status).toBe(503); }), - ); + )); +}); - expect(requestTimes).toHaveLength(6); - const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; - expect(HttpClientError.isHttpClientError(error)).toBe(true); - if (!HttpClientError.isHttpClientError(error) || error.reason._tag !== "StatusCodeError") { - throw new Error("expected StatusCodeError"); +describe("API errors", () => { + test("keep their plain Error shape", () => { + const cause = new Error("schema failure"); + const configError = new SupabaseApiConfigError("Missing access token."); + const inputError = new SupabaseApiInputError("Invalid request."); + const causedInputError = new SupabaseApiInputError("Invalid request.", { cause }); + + for (const error of [configError, inputError, causedInputError]) { + expect(error).toBeInstanceOf(Error); + expect(Object.keys(error)).toEqual(["_tag", "name"]); + expect(error.toJSON()).toEqual({ _tag: error._tag, name: error.name }); + expect(Reflect.get(error, NodeInspectSymbol)).toBeUndefined(); } - expect(error.reason.response.status).toBe(503); + expect(String(configError)).toBe("SupabaseApiConfigError: Missing access token."); + expect("cause" in inputError).toBe(false); + expect(causedInputError.cause).toBe(cause); }); }); diff --git a/packages/api/src/internal/effect-client.ts b/packages/api/src/internal/effect-client.ts index 804c91b65e..deb13b4917 100644 --- a/packages/api/src/internal/effect-client.ts +++ b/packages/api/src/internal/effect-client.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import type * as EffectModule from "effect/Effect"; -import { type SupabaseApiClientShape, SupabaseApiClient } from "./client.ts"; +import { type SupabaseApiClientShape, type SupabaseApiError, SupabaseApiClient } from "./client.ts"; export type EffectClient = { readonly [Key in keyof Operations]: Operations[Key] extends ( @@ -27,7 +27,7 @@ export function makeEffectApiClient( ( value as ( ...args: ReadonlyArray - ) => Effect.Effect + ) => Effect.Effect )(...args).pipe(Effect.provideService(SupabaseApiClient, client)); } if (isRecord(value)) { diff --git a/packages/api/src/internal/promise-client.ts b/packages/api/src/internal/promise-client.ts index 7742884cb4..48f9854ed2 100644 --- a/packages/api/src/internal/promise-client.ts +++ b/packages/api/src/internal/promise-client.ts @@ -1,6 +1,8 @@ import type * as ManagedRuntime from "effect/ManagedRuntime"; import type * as Effect from "effect/Effect"; +import type { SupabaseApiError } from "./client.ts"; + type PromiseClientOperations = { readonly [Key in keyof Operations]: Operations[Key] extends ( ...args: infer Args @@ -27,9 +29,11 @@ export function makePromiseClient( if (typeof value === "function") { return (...args: ReadonlyArray) => runtime.runPromise( - (value as (...args: ReadonlyArray) => Effect.Effect)( - ...args, - ), + ( + value as ( + ...args: ReadonlyArray + ) => Effect.Effect + )(...args), ); } if (isRecord(value)) { diff --git a/packages/api/src/internal/promise-client.unit.test.ts b/packages/api/src/internal/promise-client.unit.test.ts index 3bded77339..fd66c1ec57 100644 --- a/packages/api/src/internal/promise-client.unit.test.ts +++ b/packages/api/src/internal/promise-client.unit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { Effect, Layer, ManagedRuntime, Option } from "effect"; +import { Effect, Layer, ManagedRuntime, Option, Schema } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -51,15 +51,10 @@ function formDataTextValue(formData: FormData, key: string): string { return value; } -async function formDataFileTexts(formData: FormData, key: string): Promise> { +function formDataFileTexts(formData: FormData, key: string): Effect.Effect> { const values = formData.getAll(key); - return Promise.all( - values.map(async (value) => { - if (typeof value === "string") { - return value; - } - return value.text(); - }), + return Effect.forEach(values, (value) => + typeof value === "string" ? Effect.succeed(value) : Effect.promise(() => value.text()), ); } @@ -78,7 +73,7 @@ const config = { } as const; describe("makePromiseClient", () => { - test("preserves only the versioned facade namespace", async () => { + test("preserves only the versioned facade namespace", () => { const seenRequests: Array<{ method: string; url: string }> = []; const runtime = ManagedRuntime.make( httpClientLayer((request) => { @@ -146,50 +141,54 @@ describe("makePromiseClient", () => { }), ); - try { - const effectClient = await runtime.runPromise(makeApiClient(config)); - const client = makePromiseClient(runtime, effectClient); + return Effect.runPromise( + Effect.gen(function* () { + const effectClient = yield* Effect.promise(() => runtime.runPromise(makeApiClient(config))); + const client = makePromiseClient(runtime, effectClient); - expect("createAProject" in client).toBe(false); - expect("getProject" in client).toBe(false); - expect("listAllProjects" in client).toBe(false); - expect(typeof client.v1.createAProject).toBe("function"); - expect(typeof client.v1.getProject).toBe("function"); - expect(typeof client.v1.listAllProjects).toBe("function"); + expect("createAProject" in client).toBe(false); + expect("getProject" in client).toBe(false); + expect("listAllProjects" in client).toBe(false); + expect(typeof client.v1.createAProject).toBe("function"); + expect(typeof client.v1.getProject).toBe("function"); + expect(typeof client.v1.listAllProjects).toBe("function"); - const created = await client.v1.createAProject({ - db_pass: "hunter2", - name: "project-name", - organization_slug: "my-org", - }); - const project = await client.v1.getProject({ - ref: "abcdefghijklmnopqrst", - }); - const projects = await client.v1.listAllProjects(); + const created = yield* Effect.promise(() => + client.v1.createAProject({ + db_pass: "hunter2", + name: "project-name", + organization_slug: "my-org", + }), + ); + const project = yield* Effect.promise(() => + client.v1.getProject({ + ref: "abcdefghijklmnopqrst", + }), + ); + const projects = yield* Effect.promise(() => client.v1.listAllProjects()); - expect(created.ref).toBe("abcdefghijklmnopqrst"); - expect(project.database.host).toBe("db.supabase.internal"); - expect(projects).toHaveLength(1); - expect(seenRequests).toEqual([ - { - method: "POST", - url: "https://api.supabase.com/v1/projects", - }, - { - method: "GET", - url: "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst", - }, - { - method: "GET", - url: "https://api.supabase.com/v1/projects", - }, - ]); - } finally { - await runtime.dispose(); - } + expect(created.ref).toBe("abcdefghijklmnopqrst"); + expect(project.database.host).toBe("db.supabase.internal"); + expect(projects).toHaveLength(1); + expect(seenRequests).toEqual([ + { + method: "POST", + url: "https://api.supabase.com/v1/projects", + }, + { + method: "GET", + url: "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst", + }, + { + method: "GET", + url: "https://api.supabase.com/v1/projects", + }, + ]); + }).pipe(Effect.ensuring(Effect.promise(() => runtime.dispose()))), + ); }); - test("serializes generated multipart methods through the promise facade", async () => { + test("serializes generated multipart methods through the promise facade", () => { let seenRequest: HttpClientRequest.HttpClientRequest | undefined; const runtime = ManagedRuntime.make( @@ -212,59 +211,65 @@ describe("makePromiseClient", () => { }), ); - try { - const effectClient = await runtime.runPromise(makeApiClient(config)); - const client = makePromiseClient(runtime, effectClient); + return Effect.runPromise( + Effect.gen(function* () { + const effectClient = yield* Effect.promise(() => runtime.runPromise(makeApiClient(config))); + const client = makePromiseClient(runtime, effectClient); - const metadata = { - entrypoint_path: "functions/demo/index.ts", - import_map_path: "functions/demo/deno.json", - verify_jwt: true, - name: "demo", - } as const; + const metadata = { + entrypoint_path: "functions/demo/index.ts", + import_map_path: "functions/demo/deno.json", + verify_jwt: true, + name: "demo", + } as const; - const result = await client.v1.deployAFunction({ - ref: "abcdefghijklmnopqrst", - slug: "demo", - bundleOnly: true, - body: { - metadata, - file: [new Uint8Array([1, 2, 3]), new Blob(["deno.json"])], - }, - }); + const result = yield* Effect.promise(() => + client.v1.deployAFunction({ + ref: "abcdefghijklmnopqrst", + slug: "demo", + bundleOnly: true, + body: { + metadata, + file: [new Uint8Array([1, 2, 3]), new Blob(["deno.json"])], + }, + }), + ); - expect(result.slug).toBe("demo"); - expect(new URL(seenRequest!.url).pathname).toBe( - "/v1/projects/abcdefghijklmnopqrst/functions/deploy", - ); - expect(requestUrlParam(seenRequest!, "slug")).toBe("demo"); - expect(requestUrlParam(seenRequest!, "bundleOnly")).toBe("true"); + expect(result.slug).toBe("demo"); + expect(new URL(seenRequest!.url).pathname).toBe( + "/v1/projects/abcdefghijklmnopqrst/functions/deploy", + ); + expect(requestUrlParam(seenRequest!, "slug")).toBe("demo"); + expect(requestUrlParam(seenRequest!, "bundleOnly")).toBe("true"); - const formData = requestFormData(seenRequest!); - expect(JSON.parse(formDataTextValue(formData, "metadata"))).toEqual(metadata); - expect(await formDataFileTexts(formData, "file")).toEqual([ - "\u0001\u0002\u0003", - "deno.json", - ]); - } finally { - await runtime.dispose(); - } + const formData = requestFormData(seenRequest!); + expect( + yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))( + formDataTextValue(formData, "metadata"), + ), + ).toEqual(metadata); + expect(yield* formDataFileTexts(formData, "file")).toEqual([ + "\u0001\u0002\u0003", + "deno.json", + ]); + }).pipe(Effect.ensuring(Effect.promise(() => runtime.dispose()))), + ); }); - test("disposes the runtime and rejects subsequent operations", async () => { + test("disposes the runtime and rejects subsequent operations", () => { const runtime = ManagedRuntime.make( httpClientLayer((request) => Effect.succeed(jsonResponse(request, 200, []))), ); - try { - const effectClient = await runtime.runPromise(makeApiClient(config)); - const client = makePromiseClient(runtime, effectClient); + return Effect.runPromise( + Effect.gen(function* () { + const effectClient = yield* Effect.promise(() => runtime.runPromise(makeApiClient(config))); + const client = makePromiseClient(runtime, effectClient); - await client.dispose(); + yield* Effect.promise(() => client.dispose()); - await expect(client.v1.listAllProjects()).rejects.toThrow(); - } finally { - await runtime.dispose(); - } + yield* Effect.promise(() => expect(client.v1.listAllProjects()).rejects.toThrow()); + }).pipe(Effect.ensuring(Effect.promise(() => runtime.dispose()))), + ); }); }); diff --git a/packages/api/src/node-lifecycle.integration.test.ts b/packages/api/src/node-lifecycle.integration.test.ts index 825d3426f7..1451d488b3 100644 --- a/packages/api/src/node-lifecycle.integration.test.ts +++ b/packages/api/src/node-lifecycle.integration.test.ts @@ -1,112 +1,112 @@ -import { createServer, type Server } from "node:http"; import type { Socket } from "node:net"; +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeHttpServerRequest from "@effect/platform-node/NodeHttpServerRequest"; import { describe, expect, test } from "vitest"; -import { Effect } from "effect"; +import { Deferred, Effect, Fiber, Predicate } from "effect"; import * as HttpClient from "effect/http/HttpClient"; +import * as HttpServer from "effect/http/HttpServer"; +import * as HttpServerRequest from "effect/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; import { createApiClient as createNodeApiClient } from "./node.ts"; -async function startApiServer() { +const startApiServer = Effect.gen(function* () { const sockets = new Set(); - const server = createServer((_request, response) => { - response.writeHead(200, { "content-type": "application/json" }); - response.end("[]"); - }); - server.on("connection", (socket) => { - sockets.add(socket); - socket.once("close", () => sockets.delete(socket)); - }); - - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); + const connection = yield* Deferred.make(); + const server = yield* HttpServer.HttpServer; + yield* server.serve( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const socket = NodeHttpServerRequest.toIncomingMessage(request).socket; + if (!sockets.has(socket)) { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + yield* Deferred.succeed(connection, socket); + } + return HttpServerResponse.text("[]", { contentType: "application/json" }); + }), + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const socket of sockets) { + socket.destroy(); + } + }), + ); - const address = server.address(); - if (address === null || typeof address === "string") { - throw new Error("Expected the API test server to have a TCP address"); + const address = server.address; + if (Predicate.isTagged(address, "UnixPathAddress")) { + return yield* Effect.die("Expected the API test server to have a TCP address"); } - return { server, sockets, url: `http://127.0.0.1:${address.port}` }; -} + return { + sockets, + connected: Deferred.await(connection), + url: `http://127.0.0.1:${address.port}`, + }; +}); -async function stopApiServer(server: Server, sockets: ReadonlySet) { - for (const socket of sockets) { - socket.destroy(); - } - await new Promise((resolve, reject) => { - server.close((error) => (error === undefined ? resolve() : reject(error))); +const socketClosed = (socket: Socket) => + Effect.callback((resume) => { + if (socket.destroyed) { + resume(Effect.void); + return; + } + const onClose = () => resume(Effect.void); + socket.once("close", onClose); + return Effect.sync(() => socket.off("close", onClose)); }); -} describe("Node client lifecycle", () => { - test("disposes the Node dispatcher and rejects operations after disposal", async () => { - const { server, sockets, url } = await startApiServer(); - let client: Awaited> | undefined; - - try { - const connected = new Promise((resolve) => server.once("connection", resolve)); - client = await createNodeApiClient({ baseUrl: url, accessToken: "test-token" }); - await expect(client.v1.listAllProjects()).resolves.toEqual([]); + test("disposes the Node dispatcher and rejects operations after disposal", () => + Effect.runPromise( + Effect.gen(function* () { + const { connected, url } = yield* startApiServer; + const client = yield* Effect.acquireRelease( + Effect.promise(() => createNodeApiClient({ baseUrl: url, accessToken: "test-token" })), + (client) => Effect.promise(() => client.dispose()), + ); + yield* Effect.promise(() => expect(client.v1.listAllProjects()).resolves.toEqual([])); - const socket = await connected; - expect(socket.destroyed).toBe(false); - const closed = new Promise((resolve) => { - if (socket.destroyed) { - resolve(); - } else { - socket.once("close", () => resolve()); - } - }); - - await client.dispose(); - await closed; - expect(socket.destroyed).toBe(true); - await expect(client.v1.listAllProjects()).rejects.toThrow(); - } finally { - if (client !== undefined) { - await client.dispose(); - } - await stopApiServer(server, sockets); - } - }); + const socket = yield* connected; + expect(socket.destroyed).toBe(false); + const closed = yield* Effect.forkChild(socketClosed(socket), { startImmediately: true }); - test("cleans up when client initialization fails", async () => { - const { server, sockets, url } = await startApiServer(); + yield* Effect.promise(() => client.dispose()); + yield* Fiber.join(closed); + expect(socket.destroyed).toBe(true); + yield* Effect.promise(() => expect(client.v1.listAllProjects()).rejects.toThrow()); + }).pipe(Effect.scoped, Effect.provide(NodeHttpServer.layerTest)), + )); - try { - const connected = new Promise((resolve) => server.once("connection", resolve)); - await expect( - createNodeApiClient( - { baseUrl: url, accessToken: "test-token" }, - { - transformClient: (client) => - Effect.gen(function* () { - yield* HttpClient.get("/initialization-check").pipe( - Effect.flatMap((response) => response.text), - Effect.orDie, - Effect.provideService(HttpClient.HttpClient, client), - ); - const socket = yield* Effect.promise(() => connected); - if (sockets.size !== 1 || socket.destroyed) { - return yield* Effect.die("initialization request socket was not open"); - } - return yield* Effect.die("client initialization failed"); - }), - }, - ), - ).rejects.toThrow("client initialization failed"); + test("cleans up when client initialization fails", () => + Effect.runPromise( + Effect.gen(function* () { + const { sockets, connected, url } = yield* startApiServer; + yield* Effect.promise(() => + expect( + createNodeApiClient( + { baseUrl: url, accessToken: "test-token" }, + { + transformClient: (client) => + Effect.gen(function* () { + yield* HttpClient.get("/initialization-check").pipe( + Effect.flatMap((response) => response.text), + Effect.orDie, + Effect.provideService(HttpClient.HttpClient, client), + ); + const socket = yield* connected; + if (sockets.size !== 1 || socket.destroyed) { + return yield* Effect.die("initialization request socket was not open"); + } + return yield* Effect.die("client initialization failed"); + }), + }, + ), + ).rejects.toThrow("client initialization failed"), + ); - const socket = await connected; - const closed = new Promise((resolve) => { - if (socket.destroyed) { - resolve(); - } else { - socket.once("close", () => resolve()); - } - }); - await closed; - expect(socket.destroyed).toBe(true); - } finally { - await stopApiServer(server, sockets); - } - }); + const socket = yield* connected; + yield* socketClosed(socket); + expect(socket.destroyed).toBe(true); + }).pipe(Effect.scoped, Effect.provide(NodeHttpServer.layerTest)), + )); }); diff --git a/packages/api/src/node.ts b/packages/api/src/node.ts index 7798542c77..f5da228d20 100644 --- a/packages/api/src/node.ts +++ b/packages/api/src/node.ts @@ -24,18 +24,15 @@ const nodeHttpClientLayer = NodeHttpClient.layerUndiciNoDispatcher.pipe( ); /** Creates a Promise API client backed by a scoped Node HTTP dispatcher. */ -export async function createApiClient( +export function createApiClient( config: SupabaseApiConfig = {}, options?: SupabaseApiClientOptions, ): Promise { const runtime = ManagedRuntime.make(nodeHttpClientLayer); - try { - const effectClient = await runtime.runPromise(makeApiClient(config, options)); - return makePromiseClient(runtime, effectClient); - } catch (error) { - await runtime.dispose(); - throw error; - } + return runtime + .runPromise(makeApiClient(config, options)) + .then((effectClient) => makePromiseClient(runtime, effectClient)) + .catch((error: unknown) => runtime.dispose().then(() => Promise.reject(error))); } /** Promise API client whose runtime and HTTP dispatcher are released by `dispose`. */