From 384a6cce416ade7fd92e77dbb36d0551a53f8696 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 16 Sep 2026 00:54:55 -0300 Subject: [PATCH 1/4] refactor(cli): extract response-body helpers in the compute api seam Both compute-api.ts and compute-logs-api.ts repeated the same read-body-as-text-or-empty and json-then-decode sequences; pull them into bodyText and decodeJsonBody so the upcoming 404 classification work has one place to read a response's body from. --- .../src/shared/compute/compute-api-status.ts | 35 ++++++++++++++----- .../src/shared/compute/compute-logs-api.ts | 16 ++++----- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/shared/compute/compute-api-status.ts b/apps/cli/src/shared/compute/compute-api-status.ts index 0042407032..6105e817e4 100644 --- a/apps/cli/src/shared/compute/compute-api-status.ts +++ b/apps/cli/src/shared/compute/compute-api-status.ts @@ -1,6 +1,7 @@ import { markSupabaseApiInputErrorAsUserInput, SupabaseApiInputError } from "@supabase/api/effect"; import { Effect, Schema } from "effect"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { CLI_UPGRADE_GUIDE_URL } from "../cli/version.ts"; import { ComputeApiNetworkError, ComputeApiUnexpectedStatusError } from "./compute.errors.ts"; @@ -39,15 +40,22 @@ export function mapRequestError(operation: string) { }; } -export const unexpectedStatus = Effect.fnUntraced(function* (options: { - readonly operation: string; - readonly status: number; - readonly body: string; -}) { - const trimmed = options.body.trim(); +/** + * The response body as text, empty when it cannot be read. Every caller wants it for an error + * message, where a failed read is not worth a second failure of its own. + */ +export const bodyText = (response: HttpClientResponse.HttpClientResponse) => + response.text.pipe(Effect.orElseSucceed(() => "")); + +/** Fails with the status the response carries, quoting whatever body came with it. */ +export const unexpectedStatus = Effect.fnUntraced(function* ( + operation: string, + response: HttpClientResponse.HttpClientResponse, +) { + const trimmed = (yield* bodyText(response)).trim(); return yield* new ComputeApiUnexpectedStatusError({ - status: options.status, - detail: `The Compute API answered ${options.status} while trying to ${options.operation}${ + status: response.status, + detail: `The Compute API answered ${response.status} while trying to ${operation}${ trimmed === "" ? "" : `: ${trimmed}` }.`, suggestion: "Retry shortly; if it persists, report it with `supabase issue`.", @@ -70,3 +78,14 @@ export const decodeBody = ( }), ), ); + +/** The response's JSON body decoded against `schema`, the shape every 2xx read here needs. */ +export const decodeJsonBody = ( + schema: Schema.Codec, + operation: string, + response: HttpClientResponse.HttpClientResponse, +) => + response.json.pipe( + Effect.mapError(mapRequestError(operation)), + Effect.flatMap((body) => decodeBody(schema, operation, body, response.status)), + ); diff --git a/apps/cli/src/shared/compute/compute-logs-api.ts b/apps/cli/src/shared/compute/compute-logs-api.ts index 0faeb0e60e..c0a51114d8 100644 --- a/apps/cli/src/shared/compute/compute-logs-api.ts +++ b/apps/cli/src/shared/compute/compute-logs-api.ts @@ -1,6 +1,11 @@ import { operationDefinitions, type ApiClient } from "@supabase/api/effect"; import { Effect, Option, Predicate, Schema } from "effect"; -import { decodeBody, mapRequestError, unexpectedStatus } from "./compute-api-status.ts"; +import { + decodeBody, + decodeJsonBody, + mapRequestError, + unexpectedStatus, +} from "./compute-api-status.ts"; import { ComputeLogsQueryFailedError, ComputeLogsRateLimitedError, @@ -161,15 +166,10 @@ export const fetchComputeLogs = Effect.fnUntraced(function* ( if (response.status !== 200) { // A rejected query or the server's 30-second timeout lands here rather than // in the 200-with-`error` branch below, so both paths have to exist. - return yield* unexpectedStatus({ - operation, - status: response.status, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); + return yield* unexpectedStatus(operation, response); } - const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); - const decoded = yield* decodeBody(LogsResponse, operation, body, response.status); + const decoded = yield* decodeJsonBody(LogsResponse, operation, response); // Checked before `result`: this endpoint reports a failed query with a 200 and // a populated `error`, so reading `result` first reports success on a failure. From f78bce25e083f31079a38e36e5fdac62a4d34126 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 16 Sep 2026 00:54:57 -0300 Subject: [PATCH 2/4] fix(cli): distinguish an unrouted compute path from a missing project The Management API answers 404 with the same not_found code for three unrelated conditions: no such project, no such route, and (on the named-compute routes) an undeployed compute. Callers read all of these as either "missing project" or "not deployed", so a route the CLI's own version has fallen behind on was misreported as "no project ref was found", sending someone to re-link a project that was never the problem. Add ComputeRouteNotFoundError and recognize the router's own "Cannot GET /..." body so a 404 from an unserved route is named for what it is, on both the collection endpoints and the named-compute get/delete routes. --- .../compute/delete/delete.integration.test.ts | 53 +++++ .../compute/list/list.integration.test.ts | 83 +++++++ .../compute/push/push.integration.test.ts | 29 +++ apps/cli/src/shared/compute/compute-api.ts | 213 +++++++++--------- apps/cli/src/shared/compute/compute.errors.ts | 14 ++ .../telemetry/__fixtures__/error-tags.txt | 1 + 6 files changed, 281 insertions(+), 112 deletions(-) diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts index b90ced4a70..71cde7a38a 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts @@ -13,6 +13,7 @@ import { ComputeDeleteNotConfirmedError, ComputeNotDeployedError, ComputeApiUnexpectedStatusError, + ComputeRouteNotFoundError, } from "../../../../shared/compute/compute.errors.ts"; import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; import { computeDelete } from "./delete.handler.ts"; @@ -420,6 +421,58 @@ describe("compute delete", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + const routerNotFound = (method: string) => ({ + status: 404, + body: { + error: { + code: "not_found", + message: `Cannot ${method} ${computeRoute("/api")}`, + }, + }, + }); + + it.live("does not read an unserved route as a compute that was never deployed", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { [getRoute]: routerNotFound("GET") }, + yes: true, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeRouteNotFoundError); + expect(error).not.toBeInstanceOf(ComputeNotDeployedError); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("does not report a delete it never reached as done", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out } = setupCompute({ + workdir: repo.dir, + routes: { ...routes, [deleteRoute]: routerNotFound("DELETE") }, + yes: true, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeRouteNotFoundError); + expect(out.stdoutText).not.toContain("Deleted Compute"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + // `deleteCompute` already treats a DELETE 404 as done; the pre-flight GET used // to contradict that, so a teardown script run twice failed the second time // for a compute in exactly the state it asked for. diff --git a/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts b/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts index 5739683b37..1d479b8cad 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts @@ -12,6 +12,8 @@ import { ProjectRefNotLinkedError } from "../../../../config/project-ref.errors. import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; import { ComputeApiUnexpectedStatusError, + ComputeProjectNotFoundError, + ComputeRouteNotFoundError, ComputeUnavailableError, } from "../../../../shared/compute/compute.errors.ts"; import { computeList } from "./list.handler.ts"; @@ -352,6 +354,87 @@ describe("compute list", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.live("reports the alpha refusal's own code as unavailable, not as a missing project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 404, + body: { + error: { + code: "not_found.compute.not_enabled", + message: "Compute is not available for this project", + }, + }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeUnavailableError); + expect(error).not.toBeInstanceOf(ComputeProjectNotFoundError); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("names the unserved route instead of the project when the API has no such route", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 404, + body: { + error: { + code: "not_found", + message: `Cannot GET /v2/projects/${COMPUTE_PROJECT_REF}/compute`, + }, + }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeRouteNotFoundError); + expect(error).not.toBeInstanceOf(ComputeProjectNotFoundError); + expect((error as ComputeRouteNotFoundError).detail).toContain( + `GET /v2/projects/${COMPUTE_PROJECT_REF}/compute`, + ); + expect((error as ComputeRouteNotFoundError).suggestion).toContain("supabase issue"); + expect((error as ComputeRouteNotFoundError).suggestion).not.toContain("supabase link"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("still reports a missing project when the 404 is the project's own", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { + [listRoute]: { + status: 404, + body: { error: { code: "not_found", message: "Not Found" } }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeProjectNotFoundError); + expect((error as ComputeProjectNotFoundError).suggestion).toContain("supabase link"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("surfaces an unexpected status rather than showing an empty list", () => Effect.gen(function* () { const repo = yield* project(); diff --git a/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts b/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts index 2811991d42..ee6e92403c 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts @@ -19,6 +19,7 @@ import { ComputeBuildFailedError, ComputeBuildTimeoutError, ComputeProjectNotFoundError, + ComputeRouteNotFoundError, ComputeUnavailableError, ComputeSourceEscapingLinkError, ComputeSourceMissingError, @@ -1084,6 +1085,34 @@ describe("compute push", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.live("names the unserved route instead of the project when the API has no such route", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: routes({ + [`POST ${computeRoute("/api/uploads")}`]: { + status: 404, + body: { + error: { + code: "not_found", + message: `Cannot POST /v2/projects/${COMPUTE_PROJECT_REF}/compute/api/uploads`, + }, + }, + }, + }), + }); + + return yield* Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeRouteNotFoundError); + expect((error as ComputeRouteNotFoundError).suggestion).not.toContain("supabase link"); + expect((error as ComputeRouteNotFoundError).suggestion).not.toContain("private alpha"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("keeps the enrolment answer for a 404 body it does not recognize", () => Effect.gen(function* () { const repo = yield* project(); diff --git a/apps/cli/src/shared/compute/compute-api.ts b/apps/cli/src/shared/compute/compute-api.ts index 173fc86257..23adb72eed 100644 --- a/apps/cli/src/shared/compute/compute-api.ts +++ b/apps/cli/src/shared/compute/compute-api.ts @@ -9,20 +9,38 @@ import { import { Effect, Option, Schedule, Schema } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; -import { decodeBody, mapRequestError, unexpectedStatus } from "./compute-api-status.ts"; +import { + bodyText, + decodeJsonBody, + mapRequestError, + unexpectedStatus, +} from "./compute-api-status.ts"; import { ComputeBuildTimeoutError, ComputeProjectNotFoundError, + ComputeRouteNotFoundError, ComputeUnavailableError, ComputeUploadFailedError, } from "./compute.errors.ts"; /** - * The seam every compute command talks to: `/v2/projects/{ref}/compute` on the Management API. A - * 404 here is overloaded — a project outside the alpha's allow-list, an unknown project ref, and - * an undeployed compute all answer the same way. A named-compute 404 is reported as "not deployed"; - * a collection-endpoint 404, where no compute name could be wrong, is split by its body instead — - * see {@link projectScoped404}. + * The seam every compute command talks to: `/v2/projects/{ref}/compute` on the Management API. + * + * Three unrelated conditions answer 404 on every route here, and the body is the only thing that + * tells them apart: + * + * | condition | body | + * | --- | --- | + * | project outside the alpha's allow-list | `{"error":{"code":"not_found.compute.not_enabled"}}` | + * | no such project | `{"error":{"code":"not_found","message":"Not Found"}}` | + * | no such route | `{"error":{"code":"not_found","message":"Cannot GET /v2/..."}}` | + * + * `GET /compute/{name}` and `DELETE /compute/{name}` add a fourth: the named compute is not + * deployed. That one carries no distinguishing body, so those two read any 404 as "not deployed" + * unless {@link refuseUnroutedPath} recognizes the router's text, which no compute name can + * explain. Every other route — list, uploads, deploy — cannot mean an absent compute (an enrolled + * project with none answers `200 {"data":[]}`, and uploads and deploy create), so those classify + * by body through {@link projectScoped404}. */ /** The compute shape the API returns, flattened out of its JSON:API envelope. */ @@ -85,45 +103,73 @@ function toComputeRecord(data: ComputeResourceData): ComputeRecord { const computeSuggestion = "Compute is in private alpha. Ask in the Supabase dashboard to have this project enrolled."; -/** - * The `error.code` a 404 carries — the only way to tell an unenrolled project from one that - * doesn't exist, since both answer 404 on the same routes: - * - * - not enrolled -> `{"error":{"code":"generic_not_found","message":"Compute is not available for this project"}}` - * - no such project -> `{"error":{"code":"not_found","message":"Not Found"}}` - */ const NotFoundBody = Schema.Struct({ - error: Schema.Struct({ code: Schema.String }), + error: Schema.Struct({ + code: Schema.String, + // Unknown rather than String: a non-string message must not fail the decode and cost the + // code-based classification that follows. + message: Schema.optionalKey(Schema.Unknown), + }), }); -/** - * Which of the two a project-scoped 404 was. Only `not_found` is read as a missing project — an - * unrecognized body defaults to the enrolment answer, since guessing the other way would send - * someone to check a ref that's actually fine. - */ -const projectScoped404 = Effect.fnUntraced(function* (options: { - readonly projectRef: string; - readonly body: string; -}) { - const parsed = yield* Schema.decodeEffect(Schema.fromJsonString(NotFoundBody))(options.body).pipe( - Effect.option, - ); +/** Express's default for an unrouted path. Anchored so a message merely containing it cannot match. */ +const ROUTE_NOT_FOUND_MESSAGE = /^Cannot [A-Z]+ \//; + +const parse404 = (body: string) => + Schema.decodeEffect(Schema.fromJsonString(NotFoundBody))(body).pipe(Effect.option); + +/** The route named by the router's own 404 text, when the body is that rather than a handler's. */ +const unroutedPath = ( + parsed: Option.Option>, +): Option.Option => { + if (Option.isNone(parsed) || parsed.value.error.code !== "not_found") return Option.none(); + const { message } = parsed.value.error; + // `Cannot GET /v2/projects/{ref}/compute` -> `GET /v2/projects/{ref}/compute` + return typeof message === "string" && ROUTE_NOT_FOUND_MESSAGE.test(message) + ? Option.some(message.slice("Cannot ".length)) + : Option.none(); +}; + +const routeNotFound = (projectRef: string, route: string) => + new ComputeRouteNotFoundError({ + detail: `The Management API does not serve ${route}, so this CLI cannot reach compute for project ${projectRef}.`, + // Compute is an allow-listed alpha, so neither a newer CLI nor enrolment puts a route back. + suggestion: "Report it with `supabase issue`, including the route named above.", + }); + +/** Fails with whichever condition a collection-endpoint 404 was. */ +const projectScoped404 = Effect.fnUntraced(function* (projectRef: string, body: string) { + const parsed = yield* parse404(body); + const route = unroutedPath(parsed); + + if (Option.isSome(route)) return yield* routeNotFound(projectRef, route.value); if (Option.isSome(parsed) && parsed.value.error.code === "not_found") { - return new ComputeProjectNotFoundError({ - detail: `No project ${options.projectRef} was found for this account.`, + return yield* new ComputeProjectNotFoundError({ + detail: `No project ${projectRef} was found for this account.`, suggestion: "Check the project ref, or pick the project again with `supabase link`. " + "If it belongs to another account, log in with `supabase login`.", }); } - return new ComputeUnavailableError({ - detail: `Compute is not available for project ${options.projectRef}.`, + // Unavailable is the safe default for an unrecognized body: guessing the other way would send + // someone to check a ref that is actually fine. + return yield* new ComputeUnavailableError({ + detail: `Compute is not available for project ${projectRef}.`, suggestion: computeSuggestion, }); }); +/** + * Fails when a named-compute 404 came from the router, and returns otherwise so the caller can go + * on reading it as "not deployed". Without it, `delete` claims it removed something it never reached. + */ +const refuseUnroutedPath = Effect.fnUntraced(function* (projectRef: string, body: string) { + const route = unroutedPath(yield* parse404(body)); + if (Option.isSome(route)) return yield* routeNotFound(projectRef, route.value); +}); + export const listCompute = Effect.fnUntraced(function* (api: ApiClient, projectRef: string) { const operation = "list compute"; const response = yield* api @@ -131,35 +177,17 @@ export const listCompute = Effect.fnUntraced(function* (api: ApiClient, projectR .pipe(Effect.mapError(mapRequestError(operation))); if (response.status === 404) { - const error = yield* projectScoped404({ - projectRef, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); - return yield* error; + return yield* projectScoped404(projectRef, yield* bodyText(response)); } if (response.status !== 200) { - return yield* unexpectedStatus({ - operation, - status: response.status, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); + return yield* unexpectedStatus(operation, response); } - const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); - const decoded = yield* decodeBody( - V2ListAllComputeInstancesOutput, - operation, - body, - response.status, - ); + const decoded = yield* decodeJsonBody(V2ListAllComputeInstancesOutput, operation, response); return decoded.data.map(toComputeRecord); }); -/** - * One compute, or `None` when the API has no record of it — which is also what a - * project outside the alpha's allow-list answers, so callers report it as "not - * deployed" and point at `push` rather than guessing which of the two it was. - */ +/** One compute, or `None` when this project has no record of it — see the 404 notes above. */ export const getCompute = Effect.fnUntraced(function* ( api: ApiClient, projectRef: string, @@ -171,18 +199,14 @@ export const getCompute = Effect.fnUntraced(function* ( .pipe(Effect.mapError(mapRequestError(operation))); if (response.status === 404) { + yield* refuseUnroutedPath(projectRef, yield* bodyText(response)); return Option.none(); } if (response.status !== 200) { - return yield* unexpectedStatus({ - operation, - status: response.status, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); + return yield* unexpectedStatus(operation, response); } - const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); - const decoded = yield* decodeBody(V2GetAComputeInstanceOutput, operation, body, response.status); + const decoded = yield* decodeJsonBody(V2GetAComputeInstanceOutput, operation, response); return Option.some(toComputeRecord(decoded.data)); }); @@ -197,27 +221,13 @@ export const createComputeUpload = Effect.fnUntraced(function* ( .pipe(Effect.mapError(mapRequestError(operation))); if (response.status === 404) { - const error = yield* projectScoped404({ - projectRef, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); - return yield* error; + return yield* projectScoped404(projectRef, yield* bodyText(response)); } if (response.status !== 201 && response.status !== 200) { - return yield* unexpectedStatus({ - operation, - status: response.status, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); + return yield* unexpectedStatus(operation, response); } - const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); - const decoded = yield* decodeBody( - V2CreateComputeInstanceUploadOutput, - operation, - body, - response.status, - ); + const decoded = yield* decodeJsonBody(V2CreateComputeInstanceUploadOutput, operation, response); return { uploadId: decoded.data.id, url: decoded.data.attributes.url, @@ -249,11 +259,8 @@ export const uploadBuildContext = Effect.fnUntraced(function* ( Effect.mapError( (error) => new ComputeUploadFailedError({ - // Deliberately not `error.message`, which is what the other transport - // failures in this module use: it appends the URL that failed, and - // here that URL is the write-capable signature. The reason's own - // description is the part worth showing, and the destination is - // already named by the step the user is watching. + // Not `error.message` as elsewhere in this module: it appends the URL that failed, + // and here that URL is the write-capable signature. detail: `Uploading the build context failed: ${ error.reason.description ?? "the upload request did not complete" }.`, @@ -263,7 +270,7 @@ export const uploadBuildContext = Effect.fnUntraced(function* ( ); if (response.status < 200 || response.status >= 300) { - const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + const body = yield* bodyText(response); return yield* new ComputeUploadFailedError({ detail: `Uploading the build context failed with status ${response.status}${ body.trim() === "" ? "" : `: ${body.trim()}` @@ -297,27 +304,13 @@ export const deployCompute = Effect.fnUntraced(function* ( .pipe(Effect.mapError(mapRequestError(operation))); if (response.status === 404) { - const error = yield* projectScoped404({ - projectRef, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); - return yield* error; + return yield* projectScoped404(projectRef, yield* bodyText(response)); } if (response.status !== 202 && response.status !== 200 && response.status !== 201) { - return yield* unexpectedStatus({ - operation, - status: response.status, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); + return yield* unexpectedStatus(operation, response); } - const body = yield* response.json.pipe(Effect.mapError(mapRequestError(operation))); - const decoded = yield* decodeBody( - V2DeployAComputeInstanceOutput, - operation, - body, - response.status, - ); + const decoded = yield* decodeJsonBody(V2DeployAComputeInstanceOutput, operation, response); return toComputeRecord(decoded.data); }); @@ -333,15 +326,14 @@ export const deleteCompute = Effect.fnUntraced(function* ( // 404 is the caller's own "not deployed" verdict to report; a delete that // races another one is still a delete that happened. - if (response.status === 204 || response.status === 200 || response.status === 404) { + if (response.status === 404) { + return yield* refuseUnroutedPath(projectRef, yield* bodyText(response)); + } + if (response.status === 204 || response.status === 200) { return; } - return yield* unexpectedStatus({ - operation, - status: response.status, - body: yield* response.text.pipe(Effect.orElseSucceed(() => "")), - }); + return yield* unexpectedStatus(operation, response); }); /** @@ -373,22 +365,19 @@ export const awaitComputeBuild = Effect.fnUntraced(function* ( /** Called with each poll's result, for progress reporting. */ readonly onPoll?: (compute: ComputeRecord) => Effect.Effect; /** - * ` --project-ref ` to append to the suggestion below, when the caller reached this - * project via the flag rather than a link. The suggestion is copied verbatim, so omitting it - * would re-resolve against whatever this checkout happens to be linked to. + * ` --project-ref ` to append to the timeout suggestion, so re-running it cannot + * re-resolve against whatever this checkout happens to be linked to. */ readonly refSuffix?: string; } = {}, ) { const poll = Effect.gen(function* () { - // A build can run for minutes, so a single blip on one read should not throw - // away a deploy that is progressing fine. + // A build runs for minutes; one blip on one read must not abandon a deploy that is fine. const compute = yield* getCompute(api, projectRef, name).pipe( Effect.retry({ schedule: options.retrySchedule ?? COMPUTE_POLL_READ_RETRY }), ); if (Option.isNone(compute)) { - // The deploy was accepted, so the compute exists; a 404 here is the read - // racing the write. Report it as still building and poll again. + // The deploy was accepted, so a 404 here is the read racing the write, not an absence. return undefined; } if (options.onPoll !== undefined) { diff --git a/apps/cli/src/shared/compute/compute.errors.ts b/apps/cli/src/shared/compute/compute.errors.ts index 94ee2c5859..bc678c1ebe 100644 --- a/apps/cli/src/shared/compute/compute.errors.ts +++ b/apps/cli/src/shared/compute/compute.errors.ts @@ -231,6 +231,20 @@ export class ComputeProjectNotFoundError extends Data.TaggedError("ComputeProjec } } +/** + * The Management API serves no such route. Separated from {@link ComputeProjectNotFoundError} + * because the router answers an unrouted path under the same `not_found` code a missing project + * does, and blaming the project ref sends someone whose project is fine nowhere useful. + */ +export class ComputeRouteNotFoundError extends Data.TaggedError("ComputeRouteNotFoundError")<{ + readonly detail: string; + readonly suggestion: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.apiStatus, fingerprint_suffix: "not_found" }; + } +} + /** * Any other status the Compute routes answered with. Classified from the status it carries rather * than bucketed as a generic service failure — a 401 is the user's to fix by logging in, a 403 by diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 73052d22ba..56b0e0c8f9 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -60,6 +60,7 @@ ComputeLogsUsageExceededError ComputeNewWorkdirError ComputeNotDeployedError ComputeProjectNotFoundError +ComputeRouteNotFoundError ComputeSourceEscapingLinkError ComputeSourceMissingError ComputeStacksValidationError From 7aab754b03d6ddb9b686184dc446f24a050b3c75 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Wed, 16 Sep 2026 01:03:35 -0300 Subject: [PATCH 3/4] fix(cli): report the alpha refusal on the named compute routes `status` and `delete` read every 404 the compute route answered as "not deployed", so a project outside the alpha was told its compute was missing. Branch on the `not_found.compute.not_enabled` code the API now carries, on both route families, and point at the public alpha rather than at enrolment. --- .../compute/delete/delete.integration.test.ts | 33 +++++++++++++ .../compute/status/status.integration.test.ts | 32 +++++++++++++ apps/cli/src/shared/compute/compute-api.ts | 46 ++++++++++++------- .../src/shared/compute/compute-logs-api.ts | 3 +- 4 files changed, 95 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts index 71cde7a38a..ed04b90aa8 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts @@ -12,6 +12,7 @@ import { ComputeDeleteConfirmationRequiredError, ComputeDeleteNotConfirmedError, ComputeNotDeployedError, + ComputeUnavailableError, ComputeApiUnexpectedStatusError, ComputeRouteNotFoundError, } from "../../../../shared/compute/compute.errors.ts"; @@ -452,6 +453,38 @@ describe("compute delete", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + const notEnrolled = { + status: 404, + body: { + error: { + code: "not_found.compute.not_enabled", + message: "Compute is not available for this project", + }, + }, + }; + + it.live("names the alpha, not an undeployed compute, when the project is not enrolled", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out } = setupCompute({ + workdir: repo.dir, + routes: { [getRoute]: notEnrolled }, + yes: true, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeUnavailableError); + expect(error).not.toBeInstanceOf(ComputeNotDeployedError); + expect(out.stdoutText).not.toContain("Deleted Compute"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("does not report a delete it never reached as done", () => Effect.gen(function* () { const repo = yield* project(); diff --git a/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts b/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts index 5e577fce9f..8bff782808 100644 --- a/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts @@ -11,6 +11,7 @@ import { import { InvalidComputeNameError, ComputeNotDeployedError, + ComputeUnavailableError, } from "../../../../shared/compute/compute.errors.ts"; import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; import { computeStatus } from "./status.handler.ts"; @@ -280,6 +281,37 @@ describe("compute status", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.live("names the alpha, not an undeployed compute, when the project is not enrolled", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 404, + body: { + error: { + code: "not_found.compute.not_enabled", + message: "Compute is not available for this project", + }, + }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeStatus({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeUnavailableError); + expect(error).not.toBeInstanceOf(ComputeNotDeployedError); + expect((error as ComputeUnavailableError).suggestion).toContain("private alpha"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("refuses a name that could never have been written", () => Effect.gen(function* () { const repo = yield* project(); diff --git a/apps/cli/src/shared/compute/compute-api.ts b/apps/cli/src/shared/compute/compute-api.ts index 23adb72eed..b7a3bdd390 100644 --- a/apps/cli/src/shared/compute/compute-api.ts +++ b/apps/cli/src/shared/compute/compute-api.ts @@ -36,11 +36,11 @@ import { * | no such route | `{"error":{"code":"not_found","message":"Cannot GET /v2/..."}}` | * * `GET /compute/{name}` and `DELETE /compute/{name}` add a fourth: the named compute is not - * deployed. That one carries no distinguishing body, so those two read any 404 as "not deployed" - * unless {@link refuseUnroutedPath} recognizes the router's text, which no compute name can - * explain. Every other route — list, uploads, deploy — cannot mean an absent compute (an enrolled - * project with none answers `200 {"data":[]}`, and uploads and deploy create), so those classify - * by body through {@link projectScoped404}. + * deployed. That one is the only 404 with no body of its own, so those two read a 404 as "not + * deployed" once {@link refuseUnreachableCompute} has ruled out the two that are not about the + * compute at all. Every other route — list, uploads, deploy — cannot mean an absent compute (an + * enrolled project with none answers `200 {"data":[]}`, and uploads and deploy create), so those + * classify by body through {@link projectScoped404}. */ /** The compute shape the API returns, flattened out of its JSON:API envelope. */ @@ -100,8 +100,14 @@ function toComputeRecord(data: ComputeResourceData): ComputeRecord { }; } -const computeSuggestion = - "Compute is in private alpha. Ask in the Supabase dashboard to have this project enrolled."; +/** The code the API answers with for a project outside the alpha's allow-list. */ +const NOT_ENROLLED_CODE = "not_found.compute.not_enabled"; + +const notEnrolled = (projectRef: string) => + new ComputeUnavailableError({ + detail: `Compute is not available for project ${projectRef}.`, + suggestion: "Compute is in private alpha. Stay tuned for the public alpha coming soon.", + }); const NotFoundBody = Schema.Struct({ error: Schema.Struct({ @@ -130,6 +136,9 @@ const unroutedPath = ( : Option.none(); }; +const isNotEnrolled = (parsed: Option.Option>) => + Option.isSome(parsed) && parsed.value.error.code === NOT_ENROLLED_CODE; + const routeNotFound = (projectRef: string, route: string) => new ComputeRouteNotFoundError({ detail: `The Management API does not serve ${route}, so this CLI cannot reach compute for project ${projectRef}.`, @@ -143,6 +152,7 @@ const projectScoped404 = Effect.fnUntraced(function* (projectRef: string, body: const route = unroutedPath(parsed); if (Option.isSome(route)) return yield* routeNotFound(projectRef, route.value); + if (isNotEnrolled(parsed)) return yield* notEnrolled(projectRef); if (Option.isSome(parsed) && parsed.value.error.code === "not_found") { return yield* new ComputeProjectNotFoundError({ @@ -155,19 +165,21 @@ const projectScoped404 = Effect.fnUntraced(function* (projectRef: string, body: // Unavailable is the safe default for an unrecognized body: guessing the other way would send // someone to check a ref that is actually fine. - return yield* new ComputeUnavailableError({ - detail: `Compute is not available for project ${projectRef}.`, - suggestion: computeSuggestion, - }); + return yield* notEnrolled(projectRef); }); /** - * Fails when a named-compute 404 came from the router, and returns otherwise so the caller can go - * on reading it as "not deployed". Without it, `delete` claims it removed something it never reached. + * Fails when a named-compute 404 was not about the compute at all — the route is unserved, or the + * project is not in the alpha — and returns otherwise so the caller can read it as "not deployed". + * Without it, an unenrolled project is told its compute is not deployed, and `delete` claims it + * removed something it never reached. */ -const refuseUnroutedPath = Effect.fnUntraced(function* (projectRef: string, body: string) { - const route = unroutedPath(yield* parse404(body)); +const refuseUnreachableCompute = Effect.fnUntraced(function* (projectRef: string, body: string) { + const parsed = yield* parse404(body); + const route = unroutedPath(parsed); + if (Option.isSome(route)) return yield* routeNotFound(projectRef, route.value); + if (isNotEnrolled(parsed)) return yield* notEnrolled(projectRef); }); export const listCompute = Effect.fnUntraced(function* (api: ApiClient, projectRef: string) { @@ -199,7 +211,7 @@ export const getCompute = Effect.fnUntraced(function* ( .pipe(Effect.mapError(mapRequestError(operation))); if (response.status === 404) { - yield* refuseUnroutedPath(projectRef, yield* bodyText(response)); + yield* refuseUnreachableCompute(projectRef, yield* bodyText(response)); return Option.none(); } if (response.status !== 200) { @@ -327,7 +339,7 @@ export const deleteCompute = Effect.fnUntraced(function* ( // 404 is the caller's own "not deployed" verdict to report; a delete that // races another one is still a delete that happened. if (response.status === 404) { - return yield* refuseUnroutedPath(projectRef, yield* bodyText(response)); + return yield* refuseUnreachableCompute(projectRef, yield* bodyText(response)); } if (response.status === 204 || response.status === 200) { return; diff --git a/apps/cli/src/shared/compute/compute-logs-api.ts b/apps/cli/src/shared/compute/compute-logs-api.ts index c0a51114d8..7c4764fb73 100644 --- a/apps/cli/src/shared/compute/compute-logs-api.ts +++ b/apps/cli/src/shared/compute/compute-logs-api.ts @@ -159,8 +159,7 @@ export const fetchComputeLogs = Effect.fnUntraced(function* ( if (response.status === 404) { return yield* new ComputeUnavailableError({ detail: `Logs are not available for project ${projectRef}.`, - suggestion: - "Compute is in private alpha. Ask in the Supabase dashboard to have this project enrolled.", + suggestion: "Compute is in private alpha. Stay tuned for the public alpha coming soon.", }); } if (response.status !== 200) { From 7fc9f666a882b9bb27c1a5e57316924aeb02ecc5 Mon Sep 17 00:00:00 2001 From: Matt Johnston Date: Thu, 17 Sep 2026 13:14:34 -0300 Subject: [PATCH 4/4] fix(cli): tell a missing project from an undeployed compute on the named routes `not_found.compute.instance` is the code an absent compute answers with, so a plain `not_found` on `GET`/`DELETE /compute/{name}` is a missing project and now reports as one instead of "not deployed" or a delete that happened. The poll behind `push` stops retrying the verdicts a retry cannot change, the logs 404 classifies its own body rather than blaming the alpha for every one, and the 404 body reader moves next to the other shared response helpers. --- .../compute/delete/SIDE_EFFECTS.md | 18 +-- .../compute/delete/delete.handler.ts | 14 +- .../compute/delete/delete.integration.test.ts | 63 ++++++++- .../compute/list/list.integration.test.ts | 5 +- .../compute/logs/logs.integration.test.ts | 27 +++- .../compute/push/push.integration.test.ts | 72 +++++++++- .../compute/status/status.integration.test.ts | 67 +++++++++- .../src/shared/compute/compute-api-status.ts | 57 +++++++- apps/cli/src/shared/compute/compute-api.ts | 125 ++++++++---------- .../src/shared/compute/compute-logs-api.ts | 23 +++- apps/cli/src/shared/compute/compute.errors.ts | 6 +- 11 files changed, 370 insertions(+), 107 deletions(-) diff --git a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md index 3fd189ccc9..31816fc293 100644 --- a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md @@ -54,15 +54,15 @@ rather than deleting unasked. ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------------------------------------------- | -| `0` | success (a `404` on DELETE counts — it is already gone) | -| `0` | nothing deployed under that name, with `--yes` (teardown is idempotent) | -| `1` | invalid compute name | -| `1` | nothing deployed under that name, without `--yes` | -| `1` | the typed confirmation did not match the compute's name | -| `1` | confirmation needed but no interactive terminal to ask on, and no `--yes` | -| `1` | API error, or project not enrolled in the alpha | +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------ | +| `0` | success (a `404` on DELETE counts — it is already gone) | +| `0` | nothing deployed under that name, with `--yes` (teardown is idempotent) | +| `1` | invalid compute name | +| `1` | nothing deployed under that name, without `--yes` | +| `1` | the typed confirmation did not match the compute's name | +| `1` | confirmation needed but no interactive terminal to ask on, and no `--yes` | +| `1` | API error, project not enrolled in the alpha, no such project, or a route the API does not serve | ## Environment Variables diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts index c39a0fb78a..5555f4226e 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts @@ -67,12 +67,14 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu yield* rejectComputeEnvOutput(); const fetching = yield* output.task("Fetching compute..."); - // The lookup is a courtesy, not a prerequisite: it supplies the instance - // tally the confirmation quotes and the "already gone" verdict. The API - // grants the read and the delete separately — `edge_functions:read` for - // `GET`, `edge_functions:write` for `DELETE` — so a credential holding only - // the latter could not delete a compute it is entitled to delete. A refused - // read now leaves the compute *unknown* and the delete goes ahead. + // The lookup supplies the instance tally the confirmation quotes and the + // "already gone" verdict. A *refusal* is not a prerequisite: the API grants + // the read and the delete separately — `edge_functions:read` for `GET`, + // `edge_functions:write` for `DELETE` — so a credential holding only the + // latter would not be able to delete a compute it is entitled to delete. A + // refused read leaves the compute unknown and the delete goes ahead. A read + // that cannot reach compute at all — unserved route, unenrolled project, no + // such project — still aborts here, before any DELETE is sent. const lookup = yield* getCompute(api, projectRef, name).pipe( Effect.map((found) => ({ readable: true, compute: Option.getOrUndefined(found) })), Effect.catchIf( diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts index ed04b90aa8..d9cb532c76 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts @@ -14,6 +14,7 @@ import { ComputeNotDeployedError, ComputeUnavailableError, ComputeApiUnexpectedStatusError, + ComputeProjectNotFoundError, ComputeRouteNotFoundError, } from "../../../../shared/compute/compute.errors.ts"; import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; @@ -398,12 +399,17 @@ describe("compute delete", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + const notDeployed = { + status: 404, + body: { error: { code: "not_found.compute.instance", message: "Compute instance not found" } }, + }; + it.live("fails with `not deployed` before asking anything", () => Effect.gen(function* () { const repo = yield* project(); const { layer, out } = setupCompute({ workdir: repo.dir, - routes: { [getRoute]: { status: 404, body: { message: "compute not found" } } }, + routes: { [getRoute]: notDeployed }, }); return yield* Effect.gen(function* () { @@ -506,6 +512,55 @@ describe("compute delete", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + const projectNotFound = { + status: 404, + body: { error: { code: "not_found", message: "Not Found" } }, + }; + + it.live("does not read a missing project as a compute that was never deployed", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out, http } = setupCompute({ + workdir: repo.dir, + routes: { [getRoute]: projectNotFound }, + yes: true, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeProjectNotFoundError); + expect(error).not.toBeInstanceOf(ComputeNotDeployedError); + expect(http.routeKeys).toEqual([getRoute]); + expect(out.stdoutText).not.toContain("Deleted Compute"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("does not report a delete against a missing project as done", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out } = setupCompute({ + workdir: repo.dir, + routes: { ...routes, [deleteRoute]: projectNotFound }, + yes: true, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeDelete({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeProjectNotFoundError); + expect(out.stdoutText).not.toContain("Deleted Compute"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + // `deleteCompute` already treats a DELETE 404 as done; the pre-flight GET used // to contradict that, so a teardown script run twice failed the second time // for a compute in exactly the state it asked for. @@ -514,7 +569,7 @@ describe("compute delete", () => { const repo = yield* project(); const { layer, out, http } = setupCompute({ workdir: repo.dir, - routes: { [getRoute]: { status: 404, body: { message: "compute not found" } } }, + routes: { [getRoute]: notDeployed }, yes: true, }); @@ -534,7 +589,7 @@ describe("compute delete", () => { const repo = yield* project(); const { layer, out, http } = setupCompute({ workdir: repo.dir, - routes: { [getRoute]: { status: 404, body: { message: "compute not found" } } }, + routes: { [getRoute]: notDeployed }, yes: true, goOutput: "json", }); @@ -560,7 +615,7 @@ describe("compute delete", () => { const repo = yield* project(); const { layer, out } = setupCompute({ workdir: repo.dir, - routes: { ...routes, [deleteRoute]: { status: 404, body: { message: "already gone" } } }, + routes: { ...routes, [deleteRoute]: notDeployed }, yes: true, }); diff --git a/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts b/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts index 1d479b8cad..d7075695e1 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts @@ -328,7 +328,10 @@ describe("compute list", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("reports a project outside the alpha as unavailable", () => + // Until the Management API ships `not_found.compute.not_enabled`, an unenrolled + // project answers the shared `generic_not_found`, which no branch claims — so + // this pins the fallback that carries the alpha refusal until then. + it.live("still reports the alpha refusal's pre-rollout body as unavailable", () => Effect.gen(function* () { const repo = yield* project(); const { layer } = setupCompute({ diff --git a/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts index 757116a0ae..8388419b9b 100644 --- a/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts @@ -46,6 +46,7 @@ import { ComputeNotDeployedError, ComputeApiNetworkError, ComputeApiUnexpectedStatusError, + ComputeProjectNotFoundError, ComputeUnavailableError, } from "../../../../shared/compute/compute.errors.ts"; import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; @@ -529,7 +530,31 @@ describe("compute logs", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - it.live("reports a project outside the alpha for a 404", () => + it.live("points at the project ref when the logs 404 names no such project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { + [LOGS_ROUTE]: { + status: 404, + body: { error: { code: "not_found", message: "Not Found" } }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeLogs(flags()).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeProjectNotFoundError); + expect((error as ComputeProjectNotFoundError).suggestion).toContain("supabase link"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + // The analytics route is not gated on the alpha's allow-list, so only a body + // no other branch claims is left to read as the family's refusal. + it.live("reports a project outside the alpha for an unclassifiable 404", () => Effect.gen(function* () { const repo = yield* project(); const { layer } = setupCompute({ diff --git a/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts b/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts index ee6e92403c..9cc6252af7 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts @@ -1032,10 +1032,13 @@ describe("compute push", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - // Both of the next two arrive as a 404 on the same route; only `error.code` - // separates them, so they are asserted against the bodies the API really - // sends rather than a shape of our own invention. - it.live("reports a project outside the alpha as unavailable", () => + // These all arrive as a 404 on the same route; only `error.code` separates + // them, so they are asserted against the bodies the API really sends rather + // than a shape of our own invention. Until the Management API ships + // `not_found.compute.not_enabled`, an unenrolled project answers the shared + // `generic_not_found`, which no branch claims — so the first pins the + // fallback that carries the alpha refusal until then. + it.live("still reports the alpha refusal's pre-rollout body as unavailable", () => Effect.gen(function* () { const repo = yield* project(); const { layer } = setupCompute({ @@ -1062,6 +1065,33 @@ describe("compute push", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.live("reports the alpha refusal's own code as unavailable, not as a missing project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: routes({ + [`POST ${computeRoute("/api/uploads")}`]: { + status: 404, + body: { + error: { + code: "not_found.compute.not_enabled", + message: "Compute is not available for this project", + }, + }, + }, + }), + }); + + return yield* Effect.gen(function* () { + const error = yield* push().pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeUnavailableError); + expect((error as ComputeUnavailableError).suggestion).toContain("private alpha"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("points at the project ref when no such project exists", () => Effect.gen(function* () { const repo = yield* project(); @@ -1379,6 +1409,40 @@ describe("compute push", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.live("surfaces a permanent poll failure on the first read instead of retrying it", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, http } = setupCompute({ + workdir: repo.dir, + routes: routes({ + [`GET ${computeRoute("/api")}`]: { + status: 404, + body: { + error: { + code: "not_found.compute.not_enabled", + message: "Compute is not available for this project", + }, + }, + }, + }), + }); + + return yield* Effect.gen(function* () { + // A real retry schedule: a verdict the retry cannot change must not hold + // the poll open for its whole window. + const error = yield* computePush(flags(), { + pollSchedule: IMMEDIATE, + pollRetrySchedule: Schedule.recurs(3), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeUnavailableError); + expect(http.routeKeys.filter((key) => key === `GET ${computeRoute("/api")}`)).toHaveLength( + 1, + ); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("acts on the workdir's project, not the process's directory", () => Effect.gen(function* () { // `--workdir`/`SUPABASE_WORKDIR` names the project every command acts diff --git a/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts b/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts index 8bff782808..08bd6b7fd2 100644 --- a/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts @@ -11,6 +11,8 @@ import { import { InvalidComputeNameError, ComputeNotDeployedError, + ComputeProjectNotFoundError, + ComputeRouteNotFoundError, ComputeUnavailableError, } from "../../../../shared/compute/compute.errors.ts"; import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; @@ -265,7 +267,14 @@ describe("compute status", () => { const repo = yield* project(); const { layer } = setupCompute({ workdir: repo.dir, - routes: { [getRoute]: { status: 404, body: { message: "compute not found" } } }, + routes: { + [getRoute]: { + status: 404, + body: { + error: { code: "not_found.compute.instance", message: "Compute instance not found" }, + }, + }, + }, }); return yield* Effect.gen(function* () { @@ -312,6 +321,62 @@ describe("compute status", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.live("points at the project ref when no such project exists", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 404, + body: { error: { code: "not_found", message: "Not Found" } }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeStatus({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeProjectNotFoundError); + expect(error).not.toBeInstanceOf(ComputeNotDeployedError); + expect((error as ComputeProjectNotFoundError).suggestion).toContain("supabase link"); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.live("names the unserved route instead of an undeployed compute", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer } = setupCompute({ + workdir: repo.dir, + routes: { + [getRoute]: { + status: 404, + body: { + error: { + code: "not_found", + message: `Cannot GET ${computeRoute("/api")}`, + }, + }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const error = yield* computeStatus({ + name: "api", + projectRef: Option.none(), + }).pipe(Effect.flip); + + expect(error).toBeInstanceOf(ComputeRouteNotFoundError); + expect(error).not.toBeInstanceOf(ComputeNotDeployedError); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.live("refuses a name that could never have been written", () => Effect.gen(function* () { const repo = yield* project(); diff --git a/apps/cli/src/shared/compute/compute-api-status.ts b/apps/cli/src/shared/compute/compute-api-status.ts index 6105e817e4..56319a42fc 100644 --- a/apps/cli/src/shared/compute/compute-api-status.ts +++ b/apps/cli/src/shared/compute/compute-api-status.ts @@ -1,15 +1,20 @@ import { markSupabaseApiInputErrorAsUserInput, SupabaseApiInputError } from "@supabase/api/effect"; -import { Effect, Schema } from "effect"; +import { Effect, Option, Schema } from "effect"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { CLI_UPGRADE_GUIDE_URL } from "../cli/version.ts"; -import { ComputeApiNetworkError, ComputeApiUnexpectedStatusError } from "./compute.errors.ts"; +import { + ComputeApiNetworkError, + ComputeApiUnexpectedStatusError, + ComputeProjectNotFoundError, +} from "./compute.errors.ts"; /** * Status handling shared by every Compute API seam: the compute routes and the analytics logs * endpoint fail the same three ways (the request never left, the server answered something - * unexpected, or the body couldn't be read). Route-specific status meaning — like - * `projectScoped404`, which disambiguates a `/v2/projects/{ref}/compute` 404 by body — stays with its own route. + * unexpected, or the body couldn't be read), and both have to tell several 404s apart by body. + * Reading a 404 body is shared here; what each code *means* on a given route — like + * `projectScoped404` — stays with that route. */ /** @@ -89,3 +94,47 @@ export const decodeJsonBody = ( Effect.mapError(mapRequestError(operation)), Effect.flatMap((body) => decodeBody(schema, operation, body, response.status)), ); + +/** + * The Management API's error envelope, as it arrives on a 404. + * + * `message` is `Unknown` rather than `String` so a non-string message cannot fail the decode and + * cost the code-based classification that follows it. + */ +const NotFoundBody = Schema.Struct({ + error: Schema.Struct({ + code: Schema.String, + message: Schema.optionalKey(Schema.Unknown), + }), +}); + +type NotFoundEnvelope = Schema.Schema.Type; + +/** The 404 body parsed into its envelope, or `None` when it is something else entirely. */ +export const parse404 = (body: string) => + Schema.decodeEffect(Schema.fromJsonString(NotFoundBody))(body).pipe(Effect.option); + +/** Express's default for an unrouted path. Anchored so a message merely containing it cannot match. */ +const ROUTE_NOT_FOUND_MESSAGE = /^Cannot [A-Z]+ \//; + +/** The route named by the router's own 404 text, when the body is that rather than a handler's. */ +export const unroutedPath = (parsed: Option.Option): Option.Option => { + if (Option.isNone(parsed)) return Option.none(); + const { message } = parsed.value.error; + // `Cannot GET /v2/projects/{ref}/compute` -> `GET /v2/projects/{ref}/compute` + return typeof message === "string" && ROUTE_NOT_FOUND_MESSAGE.test(message) + ? Option.some(message.slice("Cannot ".length)) + : Option.none(); +}; + +export const hasErrorCode = (parsed: Option.Option, code: string) => + Option.isSome(parsed) && parsed.value.error.code === code; + +/** The ref names no project this account can see — the same verdict on every seam that reads one. */ +export const projectNotFound = (projectRef: string) => + new ComputeProjectNotFoundError({ + detail: `No project ${projectRef} was found for this account.`, + suggestion: + "Check the project ref, or pick the project again with `supabase link`. " + + "If it belongs to another account, log in with `supabase login`.", + }); diff --git a/apps/cli/src/shared/compute/compute-api.ts b/apps/cli/src/shared/compute/compute-api.ts index b7a3bdd390..ef1a6662db 100644 --- a/apps/cli/src/shared/compute/compute-api.ts +++ b/apps/cli/src/shared/compute/compute-api.ts @@ -6,14 +6,18 @@ import { V2ListAllComputeInstancesOutput, type ApiClient, } from "@supabase/api/effect"; -import { Effect, Option, Schedule, Schema } from "effect"; +import { Effect, Option, Schedule } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import { bodyText, decodeJsonBody, + hasErrorCode, mapRequestError, + parse404, + projectNotFound, unexpectedStatus, + unroutedPath, } from "./compute-api-status.ts"; import { ComputeBuildTimeoutError, @@ -26,21 +30,22 @@ import { /** * The seam every compute command talks to: `/v2/projects/{ref}/compute` on the Management API. * - * Three unrelated conditions answer 404 on every route here, and the body is the only thing that - * tells them apart: + * Four unrelated conditions answer 404 here, and `error.code` is the only thing that tells them + * apart: * - * | condition | body | + * | condition | code | * | --- | --- | - * | project outside the alpha's allow-list | `{"error":{"code":"not_found.compute.not_enabled"}}` | - * | no such project | `{"error":{"code":"not_found","message":"Not Found"}}` | - * | no such route | `{"error":{"code":"not_found","message":"Cannot GET /v2/..."}}` | + * | project outside the alpha's allow-list | `not_found.compute.not_enabled` | + * | no such project | `not_found` | + * | no such route | `not_found`, with the router's own `Cannot GET /v2/...` message | + * | no compute deployed under that name | `not_found.compute.instance` | * - * `GET /compute/{name}` and `DELETE /compute/{name}` add a fourth: the named compute is not - * deployed. That one is the only 404 with no body of its own, so those two read a 404 as "not - * deployed" once {@link refuseUnreachableCompute} has ruled out the two that are not about the - * compute at all. Every other route — list, uploads, deploy — cannot mean an absent compute (an - * enrolled project with none answers `200 {"data":[]}`, and uploads and deploy create), so those - * classify by body through {@link projectScoped404}. + * Only `GET /compute/{name}` and `DELETE /compute/{name}` can mean the last one: every other + * route — list, uploads, deploy — cannot mean an absent compute, since an enrolled project with + * none answers `200 {"data":[]}` and uploads and deploy create. So those classify through + * {@link projectScoped404}, and the two named routes go through + * {@link refuseUnreachableCompute}, which fails on the first three and returns on anything else + * so the caller can read it as "not deployed". */ /** The compute shape the API returns, flattened out of its JSON:API envelope. */ @@ -109,36 +114,6 @@ const notEnrolled = (projectRef: string) => suggestion: "Compute is in private alpha. Stay tuned for the public alpha coming soon.", }); -const NotFoundBody = Schema.Struct({ - error: Schema.Struct({ - code: Schema.String, - // Unknown rather than String: a non-string message must not fail the decode and cost the - // code-based classification that follows. - message: Schema.optionalKey(Schema.Unknown), - }), -}); - -/** Express's default for an unrouted path. Anchored so a message merely containing it cannot match. */ -const ROUTE_NOT_FOUND_MESSAGE = /^Cannot [A-Z]+ \//; - -const parse404 = (body: string) => - Schema.decodeEffect(Schema.fromJsonString(NotFoundBody))(body).pipe(Effect.option); - -/** The route named by the router's own 404 text, when the body is that rather than a handler's. */ -const unroutedPath = ( - parsed: Option.Option>, -): Option.Option => { - if (Option.isNone(parsed) || parsed.value.error.code !== "not_found") return Option.none(); - const { message } = parsed.value.error; - // `Cannot GET /v2/projects/{ref}/compute` -> `GET /v2/projects/{ref}/compute` - return typeof message === "string" && ROUTE_NOT_FOUND_MESSAGE.test(message) - ? Option.some(message.slice("Cannot ".length)) - : Option.none(); -}; - -const isNotEnrolled = (parsed: Option.Option>) => - Option.isSome(parsed) && parsed.value.error.code === NOT_ENROLLED_CODE; - const routeNotFound = (projectRef: string, route: string) => new ComputeRouteNotFoundError({ detail: `The Management API does not serve ${route}, so this CLI cannot reach compute for project ${projectRef}.`, @@ -146,42 +121,34 @@ const routeNotFound = (projectRef: string, route: string) => suggestion: "Report it with `supabase issue`, including the route named above.", }); -/** Fails with whichever condition a collection-endpoint 404 was. */ -const projectScoped404 = Effect.fnUntraced(function* (projectRef: string, body: string) { +/** + * Fails when a named-compute 404 was not about the compute at all — the route is unserved, the + * project is not in the alpha, or there is no such project — and returns otherwise so the caller + * can read it as "not deployed". Without it, an unenrolled project is told its compute is not + * deployed, and `delete` claims it removed something it never reached. + * + * The absence itself carries `not_found.compute.instance`, so it falls through here along with + * any body this CLI does not recognize: on these two routes "not deployed" is the 404 that + * nothing else claimed. + */ +const refuseUnreachableCompute = Effect.fnUntraced(function* (projectRef: string, body: string) { const parsed = yield* parse404(body); const route = unroutedPath(parsed); if (Option.isSome(route)) return yield* routeNotFound(projectRef, route.value); - if (isNotEnrolled(parsed)) return yield* notEnrolled(projectRef); - - if (Option.isSome(parsed) && parsed.value.error.code === "not_found") { - return yield* new ComputeProjectNotFoundError({ - detail: `No project ${projectRef} was found for this account.`, - suggestion: - "Check the project ref, or pick the project again with `supabase link`. " + - "If it belongs to another account, log in with `supabase login`.", - }); - } + if (hasErrorCode(parsed, NOT_ENROLLED_CODE)) return yield* notEnrolled(projectRef); + if (hasErrorCode(parsed, "not_found")) return yield* projectNotFound(projectRef); +}); + +/** Fails with whichever condition a collection-endpoint 404 was; none of them can mean an absence there. */ +const projectScoped404 = Effect.fnUntraced(function* (projectRef: string, body: string) { + yield* refuseUnreachableCompute(projectRef, body); // Unavailable is the safe default for an unrecognized body: guessing the other way would send // someone to check a ref that is actually fine. return yield* notEnrolled(projectRef); }); -/** - * Fails when a named-compute 404 was not about the compute at all — the route is unserved, or the - * project is not in the alpha — and returns otherwise so the caller can read it as "not deployed". - * Without it, an unenrolled project is told its compute is not deployed, and `delete` claims it - * removed something it never reached. - */ -const refuseUnreachableCompute = Effect.fnUntraced(function* (projectRef: string, body: string) { - const parsed = yield* parse404(body); - const route = unroutedPath(parsed); - - if (Option.isSome(route)) return yield* routeNotFound(projectRef, route.value); - if (isNotEnrolled(parsed)) return yield* notEnrolled(projectRef); -}); - export const listCompute = Effect.fnUntraced(function* (api: ApiClient, projectRef: string) { const operation = "list compute"; const response = yield* api @@ -336,8 +303,10 @@ export const deleteCompute = Effect.fnUntraced(function* ( .executeRaw(operationDefinitions.v2DeleteAComputeInstance, { ref: projectRef, name }) .pipe(Effect.mapError(mapRequestError(operation))); - // 404 is the caller's own "not deployed" verdict to report; a delete that - // races another one is still a delete that happened. + // A 404 no other condition claimed is the caller's own "not deployed" verdict + // to report; a delete that races another one is still a delete that happened. + // The three that `refuseUnreachableCompute` does claim fail instead, so this + // cannot report removing something it never reached. if (response.status === 404) { return yield* refuseUnreachableCompute(projectRef, yield* bodyText(response)); } @@ -366,6 +335,15 @@ const COMPUTE_POLL_READ_RETRY = Schedule.spaced("2 seconds").pipe( Schedule.upTo({ duration: "30 seconds" }), ); +/** + * Verdicts about the route, the project or its enrolment, none of which a retry can change — so + * these surface on the first read instead of holding the poll open for the full retry window. + */ +const isPermanentReadFailure = (error: unknown) => + error instanceof ComputeRouteNotFoundError || + error instanceof ComputeUnavailableError || + error instanceof ComputeProjectNotFoundError; + export const awaitComputeBuild = Effect.fnUntraced(function* ( api: ApiClient, projectRef: string, @@ -386,7 +364,10 @@ export const awaitComputeBuild = Effect.fnUntraced(function* ( const poll = Effect.gen(function* () { // A build runs for minutes; one blip on one read must not abandon a deploy that is fine. const compute = yield* getCompute(api, projectRef, name).pipe( - Effect.retry({ schedule: options.retrySchedule ?? COMPUTE_POLL_READ_RETRY }), + Effect.retry({ + schedule: options.retrySchedule ?? COMPUTE_POLL_READ_RETRY, + while: (error) => !isPermanentReadFailure(error), + }), ); if (Option.isNone(compute)) { // The deploy was accepted, so a 404 here is the read racing the write, not an absence. diff --git a/apps/cli/src/shared/compute/compute-logs-api.ts b/apps/cli/src/shared/compute/compute-logs-api.ts index 7c4764fb73..12190965c3 100644 --- a/apps/cli/src/shared/compute/compute-logs-api.ts +++ b/apps/cli/src/shared/compute/compute-logs-api.ts @@ -1,15 +1,21 @@ import { operationDefinitions, type ApiClient } from "@supabase/api/effect"; import { Effect, Option, Predicate, Schema } from "effect"; import { + bodyText, decodeBody, decodeJsonBody, + hasErrorCode, mapRequestError, + parse404, + projectNotFound, unexpectedStatus, + unroutedPath, } from "./compute-api-status.ts"; import { ComputeLogsQueryFailedError, ComputeLogsRateLimitedError, ComputeLogsUsageExceededError, + ComputeRouteNotFoundError, ComputeUnavailableError, } from "./compute.errors.ts"; import { computeLogsQuery } from "./compute-logs.sql.ts"; @@ -154,9 +160,22 @@ export const fetchComputeLogs = Effect.fnUntraced(function* ( suggestion: "Wait a minute before retrying, and avoid running several tails at once.", }); } - // The route gates on the same private-alpha allow-list as the rest of the - // family, and answers 404 for a project outside it. + // Unlike the compute routes this one is not gated on the alpha's allow-list, + // so its 404 is about the project or the route, and only an unrecognized body + // is left to read as the family's usual refusal. if (response.status === 404) { + const parsed = yield* parse404(yield* bodyText(response)); + const route = unroutedPath(parsed); + + if (Option.isSome(route)) { + return yield* new ComputeRouteNotFoundError({ + detail: `The Management API does not serve ${route.value}, so this CLI cannot read logs for project ${projectRef}.`, + suggestion: "Report it with `supabase issue`, including the route named above.", + }); + } + if (hasErrorCode(parsed, "not_found")) { + return yield* projectNotFound(projectRef); + } return yield* new ComputeUnavailableError({ detail: `Logs are not available for project ${projectRef}.`, suggestion: "Compute is in private alpha. Stay tuned for the public alpha coming soon.", diff --git a/apps/cli/src/shared/compute/compute.errors.ts b/apps/cli/src/shared/compute/compute.errors.ts index bc678c1ebe..ae29737c74 100644 --- a/apps/cli/src/shared/compute/compute.errors.ts +++ b/apps/cli/src/shared/compute/compute.errors.ts @@ -204,9 +204,9 @@ export class ComputeNotDeployedError extends Data.TaggedError("ComputeNotDeploye } /** - * Compute are in private alpha: an unenrolled project's routes answer 404, indistinguishable at - * the transport level from an unknown compute — so this is only raised on collection endpoints, - * where there's no compute name that could have been wrong. + * Compute are in private alpha, and an unenrolled project's routes answer 404 with their own + * `not_found.compute.not_enabled`. Raised on every compute route, named ones included, plus as + * the fallback for a 404 body this CLI cannot classify. */ export class ComputeUnavailableError extends Data.TaggedError("ComputeUnavailableError")<{ readonly detail: string;