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 b90ced4a70..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
@@ -12,7 +12,10 @@ import {
ComputeDeleteConfirmationRequiredError,
ComputeDeleteNotConfirmedError,
ComputeNotDeployedError,
+ ComputeUnavailableError,
ComputeApiUnexpectedStatusError,
+ ComputeProjectNotFoundError,
+ ComputeRouteNotFoundError,
} from "../../../../shared/compute/compute.errors.ts";
import { ComputeEnvNotSupportedError } from "../compute.errors.ts";
import { computeDelete } from "./delete.handler.ts";
@@ -396,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* () {
@@ -420,6 +428,139 @@ 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)),
+ );
+
+ 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();
+ 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)),
+ );
+
+ 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.
@@ -428,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,
});
@@ -448,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",
});
@@ -474,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 5739683b37..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
@@ -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";
@@ -326,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({
@@ -352,6 +357,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/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 2811991d42..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
@@ -19,6 +19,7 @@ import {
ComputeBuildFailedError,
ComputeBuildTimeoutError,
ComputeProjectNotFoundError,
+ ComputeRouteNotFoundError,
ComputeUnavailableError,
ComputeSourceEscapingLinkError,
ComputeSourceMissingError,
@@ -1031,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({
@@ -1061,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();
@@ -1084,6 +1115,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();
@@ -1350,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 5e577fce9f..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,9 @@ import {
import {
InvalidComputeNameError,
ComputeNotDeployedError,
+ ComputeProjectNotFoundError,
+ ComputeRouteNotFoundError,
+ ComputeUnavailableError,
} from "../../../../shared/compute/compute.errors.ts";
import { ComputeEnvNotSupportedError } from "../compute.errors.ts";
import { computeStatus } from "./status.handler.ts";
@@ -264,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* () {
@@ -280,6 +290,93 @@ 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("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 0042407032..56319a42fc 100644
--- a/apps/cli/src/shared/compute/compute-api-status.ts
+++ b/apps/cli/src/shared/compute/compute-api-status.ts
@@ -1,14 +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.
*/
/**
@@ -39,15 +45,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 +83,58 @@ 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)),
+ );
+
+/**
+ * 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 173fc86257..ef1a6662db 100644
--- a/apps/cli/src/shared/compute/compute-api.ts
+++ b/apps/cli/src/shared/compute/compute-api.ts
@@ -6,23 +6,46 @@ 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 { decodeBody, mapRequestError, unexpectedStatus } from "./compute-api-status.ts";
+import {
+ bodyText,
+ decodeJsonBody,
+ hasErrorCode,
+ mapRequestError,
+ parse404,
+ projectNotFound,
+ unexpectedStatus,
+ unroutedPath,
+} 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.
+ *
+ * Four unrelated conditions answer 404 here, and `error.code` is the only thing that tells them
+ * apart:
+ *
+ * | condition | code |
+ * | --- | --- |
+ * | 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` |
+ *
+ * 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. */
@@ -82,46 +105,48 @@ 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 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.",
+ });
/**
- * 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:
+ * 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.
*
- * - 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"}}`
+ * 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 NotFoundBody = Schema.Struct({
- error: Schema.Struct({ code: Schema.String }),
-});
+const refuseUnreachableCompute = Effect.fnUntraced(function* (projectRef: string, body: string) {
+ const parsed = yield* parse404(body);
+ const route = unroutedPath(parsed);
-/**
- * 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,
- );
+ if (Option.isSome(route)) return yield* routeNotFound(projectRef, route.value);
+ if (hasErrorCode(parsed, NOT_ENROLLED_CODE)) return yield* notEnrolled(projectRef);
+ if (hasErrorCode(parsed, "not_found")) return yield* projectNotFound(projectRef);
+});
- if (Option.isSome(parsed) && parsed.value.error.code === "not_found") {
- return new ComputeProjectNotFoundError({
- detail: `No project ${options.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`.",
- });
- }
+/** 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);
- return new ComputeUnavailableError({
- detail: `Compute is not available for project ${options.projectRef}.`,
- suggestion: computeSuggestion,
- });
+ // 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);
});
export const listCompute = Effect.fnUntraced(function* (api: ApiClient, projectRef: string) {
@@ -131,35 +156,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 +178,14 @@ export const getCompute = Effect.fnUntraced(function* (
.pipe(Effect.mapError(mapRequestError(operation)));
if (response.status === 404) {
+ yield* refuseUnreachableCompute(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 +200,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 +238,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 +249,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 +283,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);
});
@@ -331,17 +303,18 @@ 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.
- if (response.status === 204 || response.status === 200 || response.status === 404) {
+ // 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));
+ }
+ 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);
});
/**
@@ -362,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,
@@ -373,22 +355,22 @@ 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 }),
+ Effect.retry({
+ schedule: options.retrySchedule ?? COMPUTE_POLL_READ_RETRY,
+ while: (error) => !isPermanentReadFailure(error),
+ }),
);
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-logs-api.ts b/apps/cli/src/shared/compute/compute-logs-api.ts
index 0faeb0e60e..12190965c3 100644
--- a/apps/cli/src/shared/compute/compute-logs-api.ts
+++ b/apps/cli/src/shared/compute/compute-logs-api.ts
@@ -1,10 +1,21 @@
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 {
+ 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";
@@ -149,27 +160,34 @@ 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. 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) {
// 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.
diff --git a/apps/cli/src/shared/compute/compute.errors.ts b/apps/cli/src/shared/compute/compute.errors.ts
index 94ee2c5859..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;
@@ -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
]