diff --git a/AGENTS.md b/AGENTS.md index 7207168209..105f21762a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,13 @@ pnpm test If a workspace exposes a different script set, use that workspace's `package.json` as the source of truth. -## Nx +Before finishing any task that touches more than one workspace — or any `packages/*` workspace that other workspaces depend on — also run the monorepo-wide gate that CI's `Check code quality` job runs, from the repo root, and confirm it passes: + +```sh +npx nx run-many -t types:check lint:check fmt:check knip:check +``` + +Per-workspace `check:all` runs are not a substitute: CI runs these targets across all projects, so a formatting or lint failure in any touched workspace fails the PR even when the workspace you focused on is green. Read the Nx `Failed tasks` list at the end of the output verbatim — do not judge success by grepping for passing lines, and beware that piping a check command through `tail`/`grep` masks its exit code. This repo uses Nx for task orchestration. Prefer Nx commands over running scripts directly when working across projects or when you need to understand project structure. diff --git a/apps/cli/package.json b/apps/cli/package.json index 345a397df7..6ef03a1c05 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -142,7 +142,8 @@ "@anthropic-ai/claude-agent-sdk", "@anthropic-ai/sdk", "@modelcontextprotocol/sdk" - ] + ], + "ignoreExportsUsedInFile": true }, "nx": { "targets": { diff --git a/apps/cli/src/legacy/auth/legacy-access-token.ts b/apps/cli/src/legacy/auth/legacy-access-token.ts index 0aadb70bfd..2c3ed1b942 100644 --- a/apps/cli/src/legacy/auth/legacy-access-token.ts +++ b/apps/cli/src/legacy/auth/legacy-access-token.ts @@ -17,9 +17,13 @@ const LEGACY_INVALID_ACCESS_TOKEN_MESSAGE = */ export const validateLegacyAccessToken = ( token: string, + source?: "env" | "stored", ): Effect.Effect => LEGACY_ACCESS_TOKEN_PATTERN.test(token) ? Effect.succeed(token) : Effect.fail( - new LegacyInvalidAccessTokenError({ message: LEGACY_INVALID_ACCESS_TOKEN_MESSAGE }), + new LegacyInvalidAccessTokenError({ + message: LEGACY_INVALID_ACCESS_TOKEN_MESSAGE, + source, + }), ); diff --git a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts index 2d78ea2ec8..4ca379c960 100644 --- a/apps/cli/src/legacy/auth/legacy-credentials.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-credentials.layer.ts @@ -369,14 +369,14 @@ const makeLegacyCredentials = Effect.gen(function* () { // Env takes precedence (matches access_token.go:38). if (Option.isSome(cliConfig.accessToken)) { yield* debugLogger.debug("Using access token from env var..."); - yield* validateLegacyAccessToken(Redacted.value(cliConfig.accessToken.value)); + yield* validateLegacyAccessToken(Redacted.value(cliConfig.accessToken.value), "env"); return Option.some(cliConfig.accessToken.value); } // Keyring (profile key, then legacy key). Skipped on WSL. const keyringValue = yield* readKeyring; if (Option.isSome(keyringValue)) { - yield* validateLegacyAccessToken(keyringValue.value); + yield* validateLegacyAccessToken(keyringValue.value, "stored"); return Option.some(Redacted.make(keyringValue.value)); } @@ -384,7 +384,7 @@ const makeLegacyCredentials = Effect.gen(function* () { const fileValue = yield* readFile; if (Option.isSome(fileValue)) { yield* debugLogger.debug(`Using access token from file: ${fallbackPath}`); - yield* validateLegacyAccessToken(fileValue.value); + yield* validateLegacyAccessToken(fileValue.value, "stored"); return Option.some(Redacted.make(fileValue.value)); } diff --git a/apps/cli/src/legacy/auth/legacy-errors.ts b/apps/cli/src/legacy/auth/legacy-errors.ts index 30314cb4ec..f99d230b40 100644 --- a/apps/cli/src/legacy/auth/legacy-errors.ts +++ b/apps/cli/src/legacy/auth/legacy-errors.ts @@ -1,16 +1,37 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; export class LegacyInvalidAccessTokenError extends Data.TaggedError( "LegacyInvalidAccessTokenError", )<{ readonly message: string; -}> {} + /** + * Where the malformed token was read from. An env-var token + * (`SUPABASE_ACCESS_TOKEN`) takes precedence over stored credentials, so + * `supabase login` cannot fix it — the remediation is to correct the env + * var. A stored (keyring/file) token, or an unknown source, is fixable by + * logging in again. + */ + readonly source?: "env" | "stored"; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.source === "env" ? actionability.authToken : actionability.authLogin; + } +} export class LegacyPlatformAuthRequiredError extends Data.TaggedError( "LegacyPlatformAuthRequiredError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** * Raised by `deleteProjectCredential` when removing a stored database-password @@ -21,7 +42,11 @@ export class LegacyPlatformAuthRequiredError extends Data.TaggedError( */ export class LegacyCredentialDeleteError extends Data.TaggedError("LegacyCredentialDeleteError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * Raised by `deleteAccessToken` when there is no access token to delete, i.e. @@ -33,7 +58,11 @@ export class LegacyCredentialDeleteError extends Data.TaggedError("LegacyCredent */ export class LegacyNotLoggedInError extends Data.TaggedError("LegacyNotLoggedInError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** * Raised by `deleteAccessToken` when removing the token fails for a real reason @@ -44,4 +73,8 @@ export class LegacyNotLoggedInError extends Data.TaggedError("LegacyNotLoggedInE */ export class LegacyDeleteTokenError extends Data.TaggedError("LegacyDeleteTokenError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts index 77eb406486..2093c73564 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts @@ -47,7 +47,7 @@ export const legacyMakePlatformApi = Effect.gen(function* () { // already validates the keyring/file paths; validate the env token here too so // a malformed SUPABASE_ACCESS_TOKEN fails with the invalid-token error rather // than being sent to the API. - yield* validateLegacyAccessToken(Redacted.value(configuredToken.value)); + yield* validateLegacyAccessToken(Redacted.value(configuredToken.value), "env"); return configuredToken; } return yield* credentials.getAccessToken; diff --git a/apps/cli/src/legacy/commands/backups/backups.errors.ts b/apps/cli/src/legacy/commands/backups/backups.errors.ts index b439a7210c..859f97753c 100644 --- a/apps/cli/src/legacy/commands/backups/backups.errors.ts +++ b/apps/cli/src/legacy/commands/backups/backups.errors.ts @@ -1,8 +1,21 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacyBackupListNetworkError extends Data.TaggedError("LegacyBackupListNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBackupListUnexpectedStatusError extends Data.TaggedError( "LegacyBackupListUnexpectedStatusError", @@ -10,13 +23,24 @@ export class LegacyBackupListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyBackupRestoreNetworkError extends Data.TaggedError( "LegacyBackupRestoreNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBackupRestoreUnexpectedStatusError extends Data.TaggedError( "LegacyBackupRestoreUnexpectedStatusError", @@ -24,4 +48,8 @@ export class LegacyBackupRestoreUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts index 95896f9b6f..5292d65307 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; // --------------------------------------------------------------------------- // Bootstrap-specific tagged errors. Each maps to a Go `errors.New` / failure @@ -12,21 +18,33 @@ export class LegacyBootstrapInvalidTemplateError extends Data.TaggedError( "LegacyBootstrapInvalidTemplateError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** GitHub samples listing failure — Go's `failed to list samples` (`bootstrap.go:ListSamples`). */ export class LegacyBootstrapTemplateListError extends Data.TaggedError( "LegacyBootstrapTemplateListError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** Reading the target workdir failed — Go's `failed to read workdir: %w` (`bootstrap.go:44`). */ export class LegacyBootstrapWorkdirReadError extends Data.TaggedError( "LegacyBootstrapWorkdirReadError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * User declined the overwrite prompt — Go returns `errors.New(context.Canceled)` @@ -36,14 +54,22 @@ export class LegacyBootstrapOverwriteDeclinedError extends Data.TaggedError( "LegacyBootstrapOverwriteDeclinedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} /** Template download failure — Go's `failed to download template: %w` (`bootstrap.go:downloadSample`). */ export class LegacyBootstrapTemplateDownloadError extends Data.TaggedError( "LegacyBootstrapTemplateDownloadError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** * Project health probe failed — Go's `Error status %d: %s` (non-200) or @@ -51,4 +77,18 @@ export class LegacyBootstrapTemplateDownloadError extends Data.TaggedError( */ export class LegacyBootstrapHealthError extends Data.TaggedError("LegacyBootstrapHealthError")<{ readonly message: string; -}> {} + /** Set when the health poll itself failed with a non-200; absent when the + * service reported unhealthy. */ + readonly status?: number; + /** Set when the health poll failed without any HTTP response (DNS, TLS, + * timeout) — a network failure, not an API status. */ + readonly transport?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.status !== undefined) return statusCodeActionability(this.status); + if (this.transport === true) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + return actionability.apiStatus; + } +} diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts index 0ed9b20a8a..a6ec88f6a2 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts @@ -326,9 +326,13 @@ const mapHealthError = (cause: unknown): Effect.Effect ""), Effect.map(sanitizeLegacyErrorBody), Effect.flatMap((body) => - Effect.fail(new LegacyBootstrapHealthError({ message: `Error status ${status}: ${body}` })), + Effect.fail( + new LegacyBootstrapHealthError({ message: `Error status ${status}: ${body}`, status }), + ), ), ); } - return Effect.fail(new LegacyBootstrapHealthError({ message: `Error status 0: ${cause}` })); + return Effect.fail( + new LegacyBootstrapHealthError({ message: `Error status 0: ${cause}`, transport: true }), + ); }; diff --git a/apps/cli/src/legacy/commands/branches/branches.errors.ts b/apps/cli/src/legacy/commands/branches/branches.errors.ts index 0092742e88..5402ac34b4 100644 --- a/apps/cli/src/legacy/commands/branches/branches.errors.ts +++ b/apps/cli/src/legacy/commands/branches/branches.errors.ts @@ -1,4 +1,11 @@ import { Data } from "effect"; +import { + actionability, + CliSuggestionType, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; // --------------------------------------------------------------------------- // HTTP-bound errors — one (Network + UnexpectedStatus) pair per Go errorf site. @@ -10,7 +17,14 @@ export class LegacyBranchesListNetworkError extends Data.TaggedError( "LegacyBranchesListNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesListUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesListUnexpectedStatusError", @@ -18,13 +32,24 @@ export class LegacyBranchesListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyBranchesCreateNetworkError extends Data.TaggedError( "LegacyBranchesCreateNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesCreateUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesCreateUnexpectedStatusError", @@ -32,14 +57,26 @@ export class LegacyBranchesCreateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} // Lookup phase of `branches get` (only runs when input is not UUID / not ref). export class LegacyBranchesFindNetworkError extends Data.TaggedError( "LegacyBranchesFindNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesFindUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesFindUnexpectedStatusError", @@ -47,7 +84,16 @@ export class LegacyBranchesFindUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 404 on branch lookup means the user-supplied branch name/ref did not + // match — user input, not an API failure. + if (this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } + return statusCodeActionability(this.status); + } +} // `branches get` detail phase + the resolver's UUID branch (both use // V1GetABranchConfig; Go shares the same error template). @@ -55,7 +101,14 @@ export class LegacyBranchesGetNetworkError extends Data.TaggedError( "LegacyBranchesGetNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesGetUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesGetUnexpectedStatusError", @@ -63,13 +116,31 @@ export class LegacyBranchesGetUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 404 from `V1GetABranchConfig` means the user-supplied branch id/ref did + // not match any branch — user input, not an API failure. The resolver and + // `get` handler only call this endpoint with a user-provided UUID/ref, or + // the project_ref resolved from a user-provided branch name. + if (this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } + return statusCodeActionability(this.status); + } +} export class LegacyBranchesApiKeysNetworkError extends Data.TaggedError( "LegacyBranchesApiKeysNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesApiKeysUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesApiKeysUnexpectedStatusError", @@ -77,13 +148,24 @@ export class LegacyBranchesApiKeysUnexpectedStatusError extends Data.TaggedError readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyBranchesPoolerNetworkError extends Data.TaggedError( "LegacyBranchesPoolerNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesPoolerUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesPoolerUnexpectedStatusError", @@ -91,19 +173,34 @@ export class LegacyBranchesPoolerUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyBranchesPrimaryNotFoundError extends Data.TaggedError( "LegacyBranchesPrimaryNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} export class LegacyBranchesUpdateNetworkError extends Data.TaggedError( "LegacyBranchesUpdateNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesUpdateUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesUpdateUnexpectedStatusError", @@ -111,13 +208,25 @@ export class LegacyBranchesUpdateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} export class LegacyBranchesPauseNetworkError extends Data.TaggedError( "LegacyBranchesPauseNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesPauseUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesPauseUnexpectedStatusError", @@ -125,13 +234,24 @@ export class LegacyBranchesPauseUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyBranchesUnpauseNetworkError extends Data.TaggedError( "LegacyBranchesUnpauseNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesUnpauseUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesUnpauseUnexpectedStatusError", @@ -139,13 +259,24 @@ export class LegacyBranchesUnpauseUnexpectedStatusError extends Data.TaggedError readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyBranchesDeleteNetworkError extends Data.TaggedError( "LegacyBranchesDeleteNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesDeleteUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesDeleteUnexpectedStatusError", @@ -153,13 +284,24 @@ export class LegacyBranchesDeleteUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyBranchesDisableNetworkError extends Data.TaggedError( "LegacyBranchesDisableNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyBranchesDisableUnexpectedStatusError extends Data.TaggedError( "LegacyBranchesDisableUnexpectedStatusError", @@ -167,7 +309,11 @@ export class LegacyBranchesDisableUnexpectedStatusError extends Data.TaggedError readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --------------------------------------------------------------------------- // Pure-path errors (validation, prompt-time semantics, user cancellation). @@ -177,19 +323,31 @@ export class LegacyBranchesEnvNotSupportedError extends Data.TaggedError( "LegacyBranchesEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacyBranchesCreateCancelledError extends Data.TaggedError( "LegacyBranchesCreateCancelledError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} export class LegacyBranchesBranchNameEmptyError extends Data.TaggedError( "LegacyBranchesBranchNameEmptyError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyBranchesBranchingDisabledError extends Data.TaggedError( "LegacyBranchesBranchingDisabledError", @@ -201,4 +359,13 @@ export class LegacyBranchesBranchingDisabledError extends Data.TaggedError( * `normalizeCliError` and printed after the error message in text mode. */ readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { + ...actionability.invalidInput, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + suggested_command: "supabase branches create", + }; + } +} diff --git a/apps/cli/src/legacy/commands/branches/create/create.handler.ts b/apps/cli/src/legacy/commands/branches/create/create.handler.ts index 9cf9352b1f..2e16ae20f9 100644 --- a/apps/cli/src/legacy/commands/branches/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/branches/create/create.handler.ts @@ -106,12 +106,23 @@ export const legacyBranchesCreate = Effect.fn("legacy.branches.create")(function HttpClientError.isHttpClientError(cause) && cause.response !== undefined ? cause.response.status : 0; - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "branching_limit", statusCode: status, }); - return yield* mapCreateErrorRaw(cause); + const mapped = yield* Effect.flip(mapCreateErrorRaw(cause)); + if (mapped._tag === "LegacyBranchesCreateUnexpectedStatusError") { + return yield* Effect.fail( + new LegacyBranchesCreateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); + } + return yield* Effect.fail(mapped); }), ), ); diff --git a/apps/cli/src/legacy/commands/branches/update/update.handler.ts b/apps/cli/src/legacy/commands/branches/update/update.handler.ts index 5dc3085c74..b0e4eff594 100644 --- a/apps/cli/src/legacy/commands/branches/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/branches/update/update.handler.ts @@ -78,12 +78,23 @@ export const legacyBranchesUpdate = Effect.fn("legacy.branches.update")(function : 0; // Mirrors Go's `update.go:26` — pass the resolved branch's project // ref so the entitlements check is scoped to the branch's org. - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: branchRef, featureKey: "branching_persistent", statusCode: status, }); - return yield* mapUpdateError(cause); + const mapped = yield* Effect.flip(mapUpdateError(cause)); + if (mapped._tag === "LegacyBranchesUpdateUnexpectedStatusError") { + return yield* Effect.fail( + new LegacyBranchesUpdateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); + } + return yield* Effect.fail(mapped); }), ), ); diff --git a/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts b/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts index 8bdfbb4416..2b3c166837 100644 --- a/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts +++ b/apps/cli/src/legacy/commands/config/push/push.cost-matrix.ts @@ -72,6 +72,7 @@ export const getCostMatrix = Effect.fn("legacy.config.push.cost-matrix")(functio catch: (cause) => new LegacyConfigPushListAddonsNetworkError({ message: `failed to list addons: ${String(cause)}`, + decode: true, }), }); diff --git a/apps/cli/src/legacy/commands/config/push/push.errors.ts b/apps/cli/src/legacy/commands/config/push/push.errors.ts index f0921dfdda..b9c873bc3a 100644 --- a/apps/cli/src/legacy/commands/config/push/push.errors.ts +++ b/apps/cli/src/legacy/commands/config/push/push.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Tagged errors for `supabase config push`, one per Go error path @@ -20,6 +26,17 @@ interface NetworkErrorArgs { readonly message: string; } +/** + * A network-error shape that may instead represent a 200-response body decode + * failure (`SchemaError` / `HttpBodyError` folded in by `mapLegacyHttpError`). + * `decode: true` reclassifies the failure as an API-response problem rather + * than a transport/network problem. + */ +interface DecodableNetworkErrorArgs { + readonly message: string; + readonly decode?: boolean; +} + interface StatusErrorArgs { readonly status: number; readonly body: string; @@ -29,113 +46,258 @@ interface StatusErrorArgs { /** TOML parse failure (rewraps the packages/config parse error). Aborts before any network call. */ export class LegacyConfigPushLoadConfigError extends Data.TaggedError( "LegacyConfigPushLoadConfigError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} // --- cost matrix (list addons) --------------------------------------------- export class LegacyConfigPushListAddonsNetworkError extends Data.TaggedError( "LegacyConfigPushListAddonsNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } + return actionability.externalNetwork; + } +} export class LegacyConfigPushListAddonsStatusError extends Data.TaggedError( "LegacyConfigPushListAddonsStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --- api -------------------------------------------------------------------- export class LegacyConfigPushApiReadNetworkError extends Data.TaggedError( "LegacyConfigPushApiReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushApiReadStatusError extends Data.TaggedError( "LegacyConfigPushApiReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyConfigPushApiUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushApiUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushApiUpdateStatusError extends Data.TaggedError( "LegacyConfigPushApiUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --- db.settings ------------------------------------------------------------ export class LegacyConfigPushDbReadNetworkError extends Data.TaggedError( "LegacyConfigPushDbReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushDbReadStatusError extends Data.TaggedError( "LegacyConfigPushDbReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyConfigPushDbUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushDbUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushDbUpdateStatusError extends Data.TaggedError( "LegacyConfigPushDbUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --- db.network_restrictions ------------------------------------------------ export class LegacyConfigPushNetworkRestrictionsReadNetworkError extends Data.TaggedError( "LegacyConfigPushNetworkRestrictionsReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushNetworkRestrictionsReadStatusError extends Data.TaggedError( "LegacyConfigPushNetworkRestrictionsReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyConfigPushNetworkRestrictionsUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushNetworkRestrictionsUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushNetworkRestrictionsUpdateStatusError extends Data.TaggedError( "LegacyConfigPushNetworkRestrictionsUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --- db.ssl_enforcement ----------------------------------------------------- export class LegacyConfigPushSslEnforcementReadNetworkError extends Data.TaggedError( "LegacyConfigPushSslEnforcementReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushSslEnforcementReadStatusError extends Data.TaggedError( "LegacyConfigPushSslEnforcementReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyConfigPushSslEnforcementUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushSslEnforcementUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushSslEnforcementUpdateStatusError extends Data.TaggedError( "LegacyConfigPushSslEnforcementUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --- auth ------------------------------------------------------------------- export class LegacyConfigPushAuthReadNetworkError extends Data.TaggedError( "LegacyConfigPushAuthReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushAuthReadStatusError extends Data.TaggedError( "LegacyConfigPushAuthReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyConfigPushAuthUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushAuthUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushAuthUpdateStatusError extends Data.TaggedError( "LegacyConfigPushAuthUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --- storage ---------------------------------------------------------------- export class LegacyConfigPushStorageReadNetworkError extends Data.TaggedError( "LegacyConfigPushStorageReadNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushStorageReadStatusError extends Data.TaggedError( "LegacyConfigPushStorageReadStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyConfigPushStorageUpdateNetworkError extends Data.TaggedError( "LegacyConfigPushStorageUpdateNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushStorageUpdateStatusError extends Data.TaggedError( "LegacyConfigPushStorageUpdateStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --- experimental.webhooks -------------------------------------------------- export class LegacyConfigPushEnableWebhookNetworkError extends Data.TaggedError( "LegacyConfigPushEnableWebhookNetworkError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyConfigPushEnableWebhookStatusError extends Data.TaggedError( "LegacyConfigPushEnableWebhookStatusError", -) {} +) { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts index bd073206d8..081dfa4104 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Tagged errors for `db advisors`, one per Go failure path @@ -13,7 +19,11 @@ import { Data } from "effect"; /** cobra `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` (`db.go`). */ export class LegacyDbAdvisorsMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyDbAdvisorsMutuallyExclusiveFlagsError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--linked` PreRunE: no access token. Message is Go's `utils.ErrMissingToken`; @@ -22,7 +32,11 @@ export class LegacyDbAdvisorsMutuallyExclusiveFlagsError extends Data.TaggedErro */ export class LegacyDbAdvisorsNotLoggedInError extends Data.TaggedError( "LegacyDbAdvisorsNotLoggedInError", -)<{ readonly message: string; readonly suggestion: string }> {} +)<{ readonly message: string; readonly suggestion: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** * `--linked` PreRunE: the resolved access token is malformed. Message is Go's @@ -33,44 +47,94 @@ export class LegacyDbAdvisorsNotLoggedInError extends Data.TaggedError( */ export class LegacyDbAdvisorsInvalidTokenError extends Data.TaggedError( "LegacyDbAdvisorsInvalidTokenError", -)<{ readonly message: string; readonly suggestion: string }> {} +)<{ readonly message: string; readonly suggestion: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** `failed to begin transaction: %w` (`advisors.go:105`). */ export class LegacyDbAdvisorsBeginTxError extends Data.TaggedError("LegacyDbAdvisorsBeginTxError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** `failed to prepare lint session: %w` (`advisors.go:115`). */ export class LegacyDbAdvisorsSetupError extends Data.TaggedError("LegacyDbAdvisorsSetupError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** `failed to query lints: %w` (`advisors.go:120`). */ export class LegacyDbAdvisorsQueryError extends Data.TaggedError("LegacyDbAdvisorsQueryError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} -/** `failed to fetch security advisors: %w` (`advisors.go:165`). */ +/** + * `failed to fetch security advisors: %w` (`advisors.go:165`). Go folds a + * decode error into the same message path as a transport failure — `decode` + * distinguishes them for actionability so a 200-response decode failure + * classifies as an API response problem instead of a network problem. + */ export class LegacyDbAdvisorsSecurityNetworkError extends Data.TaggedError( "LegacyDbAdvisorsSecurityNetworkError", -)<{ readonly message: string }> {} +)<{ readonly message: string; readonly decode?: boolean }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** `unexpected security advisors status %d: %s` (`advisors.go:168`). */ export class LegacyDbAdvisorsSecurityStatusError extends Data.TaggedError( "LegacyDbAdvisorsSecurityStatusError", -)<{ readonly status: number; readonly body: string; readonly message: string }> {} +)<{ readonly status: number; readonly body: string; readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} -/** `failed to fetch performance advisors: %w` (`advisors.go:176`). */ +/** + * `failed to fetch performance advisors: %w` (`advisors.go:176`). Go folds a + * decode error into the same message path as a transport failure — `decode` + * distinguishes them for actionability so a 200-response decode failure + * classifies as an API response problem instead of a network problem. + */ export class LegacyDbAdvisorsPerformanceNetworkError extends Data.TaggedError( "LegacyDbAdvisorsPerformanceNetworkError", -)<{ readonly message: string }> {} +)<{ readonly message: string; readonly decode?: boolean }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** `unexpected performance advisors status %d: %s` (`advisors.go:179`). */ export class LegacyDbAdvisorsPerformanceStatusError extends Data.TaggedError( "LegacyDbAdvisorsPerformanceStatusError", -)<{ readonly status: number; readonly body: string; readonly message: string }> {} +)<{ readonly status: number; readonly body: string; readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** `fail-on is set to %s, non-zero exit` (`advisors.go:257`). */ export class LegacyDbAdvisorsFailOnError extends Data.TaggedError("LegacyDbAdvisorsFailOnError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts b/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts index 6ef5d5aa35..0425245942 100644 --- a/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts +++ b/apps/cli/src/legacy/commands/db/advisors/advisors.linked.ts @@ -18,8 +18,15 @@ import { apiResponseToLegacyAdvisorLints } from "./advisors.format.ts"; interface AdvisorEndpoint { readonly path: "security" | "performance"; - /** Builds the network/parse failure (Go's `failed to fetch … advisors: %w`). */ - readonly network: (message: string) => LegacyAdvisorNetworkError; + /** + * Builds the network/parse failure (Go's `failed to fetch … advisors: %w`). + * `decode: true` marks a 200-response body decode failure rather than a + * transport failure, even though Go folds both into the same message path. + */ + readonly network: ( + message: string, + opts?: { readonly decode?: boolean }, + ) => LegacyAdvisorNetworkError; /** Builds the non-200 failure (Go's `unexpected … advisors status %d: %s`). */ readonly status: (status: number, body: string) => LegacyAdvisorStatusError; } @@ -92,7 +99,7 @@ const fetchAdvisors = Effect.fnUntraced(function* ( // `apiResponseToLegacyAdvisorLints`) to the endpoint's network error. return yield* Effect.try({ try: () => apiResponseToLegacyAdvisorLints(JSON.parse(rawBody) as unknown), - catch: (cause) => endpoint.network(String(cause)), + catch: (cause) => endpoint.network(String(cause), { decode: true }), }); }); @@ -101,9 +108,10 @@ export const legacyFetchSecurityAdvisors = (ref: string, stitch: LegacyStitchFn) ref, { path: "security", - network: (message) => + network: (message, opts) => new LegacyDbAdvisorsSecurityNetworkError({ message: `failed to fetch security advisors: ${message}`, + decode: opts?.decode, }), status: (status, body) => new LegacyDbAdvisorsSecurityStatusError({ @@ -120,9 +128,10 @@ export const legacyFetchPerformanceAdvisors = (ref: string, stitch: LegacyStitch ref, { path: "performance", - network: (message) => + network: (message, opts) => new LegacyDbAdvisorsPerformanceNetworkError({ message: `failed to fetch performance advisors: ${message}`, + decode: opts?.decode, }), status: (status, body) => new LegacyDbAdvisorsPerformanceStatusError({ diff --git a/apps/cli/src/legacy/commands/db/diff/diff.errors.ts b/apps/cli/src/legacy/commands/db/diff/diff.errors.ts index b7c6dff954..6da5113849 100644 --- a/apps/cli/src/legacy/commands/db/diff/diff.errors.ts +++ b/apps/cli/src/legacy/commands/db/diff/diff.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Conflicting database-target flags. Reproduces cobra's @@ -7,7 +12,11 @@ import { Data } from "effect"; */ export class LegacyDbDiffTargetFlagsError extends Data.TaggedError("LegacyDbDiffTargetFlagsError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Conflicting diff-engine flags. Reproduces cobra's @@ -18,7 +27,11 @@ export class LegacyDbDiffEngineConflictError extends Data.TaggedError( "LegacyDbDiffEngineConflictError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Only one of `--from` / `--to` was set in explicit diff mode. Byte-matches Go's @@ -29,7 +42,11 @@ export class LegacyDbDiffExplicitFlagsError extends Data.TaggedError( "LegacyDbDiffExplicitFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * An explicit `--from`/`--to` ref was neither `local`/`linked`/`migrations` nor a @@ -41,7 +58,11 @@ export class LegacyDbDiffUnknownTargetError extends Data.TaggedError( "LegacyDbDiffUnknownTargetError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Writing the diff output failed — a `--file` migration, or an explicit-mode @@ -49,4 +70,8 @@ export class LegacyDbDiffUnknownTargetError extends Data.TaggedError( */ export class LegacyDbDiffWriteError extends Data.TaggedError("LegacyDbDiffWriteError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/db/dump/dump.errors.ts b/apps/cli/src/legacy/commands/db/dump/dump.errors.ts index d7de51c62d..4c97617bc9 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.errors.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * `--use-copy` / `--exclude` were passed without `--data-only`. Reproduces @@ -9,7 +14,11 @@ export class LegacyDbDumpRequiresDataOnlyError extends Data.TaggedError( "LegacyDbDumpRequiresDataOnlyError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Two mutually exclusive flags were set together. Reproduces cobra's @@ -20,7 +29,11 @@ export class LegacyDbDumpMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyDbDumpMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Failed to open the `--file` output path. Byte-matches Go's @@ -28,7 +41,11 @@ export class LegacyDbDumpMutuallyExclusiveFlagsError extends Data.TaggedError( */ export class LegacyDbDumpOpenFileError extends Data.TaggedError("LegacyDbDumpOpenFileError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * The pg_dump container exited non-zero. Byte-matches Go's @@ -41,4 +58,8 @@ export class LegacyDbDumpRunError extends Data.TaggedError("LegacyDbDumpRunError // transaction-pooler guidance. `Output.fail` prints it bare on stderr after the // error message, mirroring Go's `recoverAndExit`. readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} diff --git a/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts b/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts index 3c16d07f08..ef76a95883 100644 --- a/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/dump/dump.integration.test.ts @@ -112,7 +112,11 @@ function mockDockerRun(opts: { allOpts.push(runOpts); if (opts.runFails === true) { return Effect.fail( - new LegacyDockerRunError({ message: "failed to run docker: not found" }), + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }), ); } const next = queue.shift(); @@ -130,7 +134,11 @@ function mockDockerRun(opts: { allOpts.push(runOpts); if (opts.runFails === true) { return yield* Effect.fail( - new LegacyDockerRunError({ message: "failed to run docker: not found" }), + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }), ); } const next = queue.shift(); diff --git a/apps/cli/src/legacy/commands/db/lint/lint.errors.ts b/apps/cli/src/legacy/commands/db/lint/lint.errors.ts index 73c295a688..de21f7b547 100644 --- a/apps/cli/src/legacy/commands/db/lint/lint.errors.ts +++ b/apps/cli/src/legacy/commands/db/lint/lint.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * Tagged errors for `db lint`, one per Go failure path * (`internal/db/lint/lint.go`). The `message` byte-matches Go's `errors.Errorf` @@ -12,34 +18,62 @@ import { Data } from "effect"; /** cobra `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` (`db.go`). */ export class LegacyDbLintMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyDbLintMutuallyExclusiveFlagsError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** `failed to begin transaction: %w` (`lint.go:111`). */ export class LegacyDbLintBeginTxError extends Data.TaggedError("LegacyDbLintBeginTxError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** `failed to list schemas: %w` (`drop.go:46`, via `ListUserSchemas`). */ export class LegacyDbLintListSchemasError extends Data.TaggedError("LegacyDbLintListSchemasError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `failed to enable pgsql_check: %w` (`lint.go:126`). */ export class LegacyDbLintEnableCheckError extends Data.TaggedError("LegacyDbLintEnableCheckError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `failed to query rows: %w` (`lint.go:140`). */ export class LegacyDbLintQueryError extends Data.TaggedError("LegacyDbLintQueryError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `failed to marshal json: %w` (`lint.go:151`). */ export class LegacyDbLintMalformedJsonError extends Data.TaggedError( "LegacyDbLintMalformedJsonError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `fail-on is set to %s, non-zero exit` (`lint.go:72`). */ export class LegacyDbLintFailOnError extends Data.TaggedError("LegacyDbLintFailOnError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/commands/db/pull/pull.errors.ts b/apps/cli/src/legacy/commands/db/pull/pull.errors.ts index 25c3c36300..2b2f038b89 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.errors.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * Conflicting database-target flags. Reproduces cobra's * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` error byte-for-byte @@ -7,7 +13,11 @@ import { Data } from "effect"; */ export class LegacyDbPullTargetFlagsError extends Data.TaggedError("LegacyDbPullTargetFlagsError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--declarative` / `--use-pg-delta` combined with `--diff-engine`. Reproduces @@ -18,7 +28,11 @@ export class LegacyDbPullEngineConflictError extends Data.TaggedError( "LegacyDbPullEngineConflictError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * The remote migration history does not match local files. Byte-matches Go's @@ -30,7 +44,11 @@ export class LegacyDbPullMigrationConflictError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} /** * The diff produced no schema changes. Byte-matches Go's `errInSync` @@ -40,7 +58,11 @@ export class LegacyDbPullMigrationConflictError extends Data.TaggedError( */ export class LegacyDbPullInSyncError extends Data.TaggedError("LegacyDbPullInSyncError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Writing the migration file / updating the remote migration-history table failed. @@ -48,7 +70,11 @@ export class LegacyDbPullInSyncError extends Data.TaggedError("LegacyDbPullInSyn */ export class LegacyDbPullWriteError extends Data.TaggedError("LegacyDbPullWriteError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * The initial-pull pg_dump container exited non-zero. Go's `dumpRemoteSchema` @@ -60,4 +86,8 @@ export class LegacyDbPullWriteError extends Data.TaggedError("LegacyDbPullWriteE export class LegacyDbPullDumpError extends Data.TaggedError("LegacyDbPullDumpError")<{ readonly message: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} diff --git a/apps/cli/src/legacy/commands/db/push/push.errors.ts b/apps/cli/src/legacy/commands/db/push/push.errors.ts index 3849d55add..a22977b18d 100644 --- a/apps/cli/src/legacy/commands/db/push/push.errors.ts +++ b/apps/cli/src/legacy/commands/db/push/push.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Conflicting database-target flags. Reproduces cobra's @@ -7,7 +12,11 @@ import { Data } from "effect"; */ export class LegacyDbPushTargetFlagsError extends Data.TaggedError("LegacyDbPushTargetFlagsError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Remote migration versions are missing from the local directory. Byte-matches @@ -19,7 +28,11 @@ export class LegacyDbPushMissingLocalError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} /** * Local migration files are ordered before the remote head and `--include-all` @@ -31,7 +44,11 @@ export class LegacyDbPushMissingRemoteError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} /** * The user declined a confirmation prompt. Go returns `errors.New(context.Canceled)` @@ -39,12 +56,20 @@ export class LegacyDbPushMissingRemoteError extends Data.TaggedError( */ export class LegacyDbPushCancelledError extends Data.TaggedError("LegacyDbPushCancelledError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} /** Locating `supabase/roles.sql` failed (Go's `failed to find custom roles: %w`). */ export class LegacyDbPushRolesError extends Data.TaggedError("LegacyDbPushRolesError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * A migration / seed / globals / vault statement failed while applying. Carries @@ -53,4 +78,8 @@ export class LegacyDbPushRolesError extends Data.TaggedError("LegacyDbPushRolesE */ export class LegacyDbPushApplyError extends Data.TaggedError("LegacyDbPushApplyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/commands/db/query/query.errors.ts b/apps/cli/src/legacy/commands/db/query/query.errors.ts index ac7f3f53e3..3bf4112a8d 100644 --- a/apps/cli/src/legacy/commands/db/query/query.errors.ts +++ b/apps/cli/src/legacy/commands/db/query/query.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * No SQL was provided by any source. Byte-matches Go's @@ -7,17 +13,29 @@ import { Data } from "effect"; */ export class LegacyDbQueryNoSqlError extends Data.TaggedError("LegacyDbQueryNoSqlError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** Stdin was piped but empty. Byte-matches Go's `"no SQL provided via stdin"`. */ export class LegacyDbQueryNoStdinSqlError extends Data.TaggedError("LegacyDbQueryNoStdinSqlError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** `--file` could not be read. Byte-matches Go's `"failed to read SQL file: " + err`. */ export class LegacyDbQueryReadFileError extends Data.TaggedError("LegacyDbQueryReadFileError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--linked` was used without an access token. Mirrors Go's PreRunE, which @@ -29,12 +47,30 @@ export class LegacyDbQueryLoginRequiredError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** Query execution failed. Byte-matches Go's `"failed to execute query: " + err`. */ export class LegacyDbQueryExecError extends Data.TaggedError("LegacyDbQueryExecError")<{ readonly message: string; -}> {} + /** + * Set when this failure came from the linked path's HTTP transport + * (`httpClient.execute`/body read against `/v1/projects/{ref}/database/query`) + * rather than the user's SQL failing to execute. + */ + readonly transport?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.transport === true) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + // The user's own SQL failed — same bucket as every sibling exec error. + return actionability.dbFinding; + } +} /** * More than one of `--db-url` / `--linked` / `--local` was set. Reproduces @@ -46,7 +82,11 @@ export class LegacyDbQueryMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyDbQueryMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * The linked Management API returned a non-201 status. Byte-matches Go's @@ -55,5 +95,10 @@ export class LegacyDbQueryMutuallyExclusiveFlagsError extends Data.TaggedError( export class LegacyDbQueryUnexpectedStatusError extends Data.TaggedError( "LegacyDbQueryUnexpectedStatusError", )<{ + readonly status: number; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/src/legacy/commands/db/query/query.handler.ts b/apps/cli/src/legacy/commands/db/query/query.handler.ts index 4a51f9e301..1e8f9bc0df 100644 --- a/apps/cli/src/legacy/commands/db/query/query.handler.ts +++ b/apps/cli/src/legacy/commands/db/query/query.handler.ts @@ -200,12 +200,17 @@ export const legacyDbQuery = Effect.fn("legacy.db.query")(function* (flags: Lega return { status: response.status, body: text }; }).pipe( Effect.mapError( - (cause) => new LegacyDbQueryExecError({ message: `failed to execute query: ${cause}` }), + (cause) => + new LegacyDbQueryExecError({ + message: `failed to execute query: ${cause}`, + transport: true, + }), ), ); if (status !== 201) { return yield* Effect.fail( new LegacyDbQueryUnexpectedStatusError({ + status, message: `unexpected status ${status}: ${body}`, }), ); diff --git a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts index bfb4c3e539..fba5290c7e 100644 --- a/apps/cli/src/legacy/commands/db/reset/reset.errors.ts +++ b/apps/cli/src/legacy/commands/db/reset/reset.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * Conflicting database-target flags. Reproduces cobra's * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` (`cmd/db.go:573`). @@ -8,7 +14,11 @@ export class LegacyDbResetTargetFlagsError extends Data.TaggedError( "LegacyDbResetTargetFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--version` and `--last` together. Reproduces cobra's @@ -18,7 +28,11 @@ export class LegacyDbResetVersionFlagsError extends Data.TaggedError( "LegacyDbResetVersionFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--version` is not a valid integer. Byte-matches Go's @@ -28,7 +42,11 @@ export class LegacyDbResetInvalidVersionError extends Data.TaggedError( "LegacyDbResetInvalidVersionError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * No migration file matches `--version`. Byte-matches Go's @@ -39,7 +57,11 @@ export class LegacyDbResetMigrationFileError extends Data.TaggedError( "LegacyDbResetMigrationFileError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * The user declined the reset confirmation. Go returns @@ -47,12 +69,20 @@ export class LegacyDbResetMigrationFileError extends Data.TaggedError( */ export class LegacyDbResetCancelledError extends Data.TaggedError("LegacyDbResetCancelledError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} /** A drop / migrate / seed / vault statement failed during the remote reset. */ export class LegacyDbResetApplyError extends Data.TaggedError("LegacyDbResetApplyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * The local database container is not running. Byte-matches Go's @@ -62,7 +92,11 @@ export class LegacyDbResetApplyError extends Data.TaggedError("LegacyDbResetAppl */ export class LegacyDbResetNotRunningError extends Data.TaggedError("LegacyDbResetNotRunningError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** * `--last` was given a negative value. Go declares `--last` as an unsigned flag @@ -71,7 +105,11 @@ export class LegacyDbResetNotRunningError extends Data.TaggedError("LegacyDbRese */ export class LegacyDbResetLastFlagError extends Data.TaggedError("LegacyDbResetLastFlagError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Invalid `--sql-paths` usage. Byte-matches Go's `validateDbResetSeedFlags` @@ -85,4 +123,8 @@ export class LegacyDbResetSeedFlagsError extends Data.TaggedError("LegacyDbReset * `validateDbResetSeedFlags` `utils.CmdSuggestion` (`cmd/db.go`). */ readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts index 4ff6cb9ab5..847a61f185 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/declarative.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../../shared/telemetry/error-actionability.ts"; + /** * Declarative commands were invoked without `--experimental` and without * `[experimental.pgdelta] enabled = true`. Byte-matches Go's gate error @@ -12,7 +18,11 @@ export class LegacyDeclarativeNotEnabledError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * A target could not be resolved in non-interactive mode. Byte-matches Go's @@ -24,7 +34,11 @@ export class LegacyDeclarativeNonInteractiveError extends Data.TaggedError( "LegacyDeclarativeNonInteractiveError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * A mutually-exclusive flag group was violated. Reproduces cobra's @@ -37,7 +51,11 @@ export class LegacyDeclarativeMutuallyExclusiveFlagsError extends Data.TaggedErr "LegacyDeclarativeMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * The interactive custom-database-URL prompt was empty or unparseable. Byte-matches @@ -48,7 +66,11 @@ export class LegacyDeclarativeInvalidDbUrlError extends Data.TaggedError( "LegacyDeclarativeInvalidDbUrlError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `db schema declarative generate` ran but produced no declarative files (sync's @@ -59,7 +81,11 @@ export class LegacyDeclarativeNoFilesGeneratedError extends Data.TaggedError( "LegacyDeclarativeNoFilesGeneratedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Diffing declarative schema to migrations failed. Wraps @@ -69,7 +95,11 @@ export class LegacyDeclarativeNoFilesGeneratedError extends Data.TaggedError( */ export class LegacyDeclarativeDiffError extends Data.TaggedError("LegacyDeclarativeDiffError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Applying the generated migration to the local database failed. Wraps Go's @@ -79,7 +109,19 @@ export class LegacyDeclarativeDiffError extends Data.TaggedError("LegacyDeclarat */ export class LegacyDeclarativeApplyError extends Data.TaggedError("LegacyDeclarativeApplyError")<{ readonly message: string; -}> {} + /** + * Set when this failure came from connecting to the local Postgres instance + * (`dbConnection.connect`) rather than the migration SQL failing to apply. + */ + readonly connect?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.connect === true) { + return { ...actionability.dbConnection, fingerprint_suffix: "connect" }; + } + return actionability.dbFinding; + } +} /** * Materializing the declarative export on disk failed. Byte-matches Go's diff --git a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts index 63f8d8a7e4..57dc29f55b 100644 --- a/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/legacy/commands/db/schema/declarative/sync/sync.handler.ts @@ -464,7 +464,9 @@ const applyMigrationToLocal = ( { isLocal: true, dnsResolver: local.dnsResolver }, ) .pipe( - Effect.mapError((error) => new LegacyDeclarativeApplyError({ message: error.message })), + Effect.mapError( + (error) => new LegacyDeclarativeApplyError({ message: error.message, connect: true }), + ), ); yield* legacyApplyMigrationFile( session, diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts index 673e78a466..ea9db1b988 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-db-bootstrap.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Driving the bundled Go binary's hidden `db __db-bootstrap` seam failed — the @@ -17,4 +22,10 @@ export class LegacyDbBootstrapError extends Data.TaggedError("LegacyDbBootstrapE * runtime's daemon is unreachable (`AssertServiceIsRunning`, `misc.go:148-154`). */ readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.suggestion !== undefined + ? { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" } + : { ...actionability.unknown, fingerprint_suffix: "seam" }; + } +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts index 642909c665..c80f93bf1e 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-migra.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * The migra diff failed (edge-runtime run, or the OOM bash fallback in the * `supabase/migra` Docker image). Byte-matches Go's @@ -8,7 +14,11 @@ import { Data } from "effect"; */ export class LegacyMigraDiffError extends Data.TaggedError("LegacyMigraDiffError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Loading the target's user-defined schemas for the migra bash fallback failed. @@ -18,4 +28,8 @@ export class LegacyMigraDiffError extends Data.TaggedError("LegacyMigraDiffError */ export class LegacyMigraSchemaLoadError extends Data.TaggedError("LegacyMigraSchemaLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts index b52d173fbe..5750a60488 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pgdelta.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * The pg-delta edge-runtime script failed. Byte-matches Go's * `": :\n"` wrapping in `RunEdgeRuntimeScript` @@ -11,7 +17,11 @@ export class LegacyDeclarativeEdgeRuntimeError extends Data.TaggedError( "LegacyDeclarativeEdgeRuntimeError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * Setting up / connecting to / migrating the throwaway shadow database failed. @@ -23,7 +33,11 @@ export class LegacyDeclarativeShadowDbError extends Data.TaggedError( "LegacyDeclarativeShadowDbError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** * Exporting declarative schema produced no output. Byte-matches Go's @@ -35,7 +49,11 @@ export class LegacyDeclarativeEmptyOutputError extends Data.TaggedError( "LegacyDeclarativeEmptyOutputError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.impossibleState; + } +} /** * Parsing the declarative export envelope failed. Byte-matches Go's @@ -46,7 +64,11 @@ export class LegacyDeclarativeParseOutputError extends Data.TaggedError( "LegacyDeclarativeParseOutputError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.impossibleState; + } +} /** * Materializing the declarative export on disk failed. Byte-matches Go's @@ -57,4 +79,8 @@ export class LegacyDeclarativeParseOutputError extends Data.TaggedError( */ export class LegacyDeclarativeWriteError extends Data.TaggedError("LegacyDeclarativeWriteError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/domains/domains.cname.ts b/apps/cli/src/legacy/commands/domains/domains.cname.ts index 07d01833e8..f8ad264083 100644 --- a/apps/cli/src/legacy/commands/domains/domains.cname.ts +++ b/apps/cli/src/legacy/commands/domains/domains.cname.ts @@ -11,6 +11,19 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +/** + * Internal discriminated failure for the CNAME verification pipeline: + * `transport: true` for resolver failures (fetch error, non-200, timeout), + * `transport: false` for a genuine finding about the user's DNS records + * (no CNAME answer). Consumed exclusively by {@link verifyLegacyCname}, which + * folds it into `LegacyDomainsCnameError` so telemetry can tell a Cloudflare + * DoH outage apart from a misconfigured record. + */ +export interface LegacyCnameFailure { + readonly transport: boolean; + readonly detail: string; +} + /** * Extract the first CNAME answer's `data` from a Cloudflare DNS-over-HTTPS JSON * response. Mirrors Go's `utils.ResolveCNAME` @@ -18,7 +31,10 @@ function isRecord(value: unknown): value is Record { * with `type === 5` and return its `data`; otherwise fail with the same * "failed to locate" message Go embeds (4-space-indented JSON of the answers). */ -export function parseFirstCname(payload: unknown, host: string): Effect.Effect { +export function parseFirstCname( + payload: unknown, + host: string, +): Effect.Effect { const answers = isRecord(payload) && Array.isArray(payload["Answer"]) ? payload["Answer"] : []; for (const answer of answers) { if (isRecord(answer) && answer["type"] === CNAME_TYPE && typeof answer["data"] === "string") { @@ -29,15 +45,16 @@ export function parseFirstCname(payload: unknown, host: string): Effect.Effect 1024 ? `${dump.slice(0, 1024)}…` : dump; - return Effect.fail( - new Error(`failed to locate appropriate CNAME record for ${host}; resolves to ${capped}`), - ); + return Effect.fail({ + transport: false, + detail: `failed to locate appropriate CNAME record for ${host}; resolves to ${capped}`, + }); } /** * Render the `%w`-wrapped cause string for the "failed to resolve" CNAME error. - * Transport / timeout / parse failures and the locate error all flow through - * here so the outer message stays Go-shaped without leaking object internals. + * Transport / timeout / parse failures all flow through here so the outer + * message stays Go-shaped without leaking object internals. */ export function formatCnameCause(cause: unknown): string { if (cause instanceof Error) return cause.message; @@ -45,6 +62,11 @@ export function formatCnameCause(cause: unknown): string { return String(cause); } +const transportFailure = (cause: unknown): LegacyCnameFailure => ({ + transport: true, + detail: formatCnameCause(cause), +}); + /** * Verify that `customHostname` has a CNAME record pointing at the project's * Supabase subdomain before initializing a custom hostname. Mirrors @@ -68,20 +90,29 @@ export const verifyLegacyCname = Effect.fnUntraced(function* (args: { ); const resolved = yield* Effect.gen(function* () { - const response = yield* args.httpClient.execute(request); + const response = yield* args.httpClient + .execute(request) + .pipe(Effect.mapError(transportFailure)); if (response.status !== 200) { - return yield* Effect.fail(new Error(`unexpected DNS query status ${response.status}`)); + return yield* Effect.fail({ + transport: true, + detail: `unexpected DNS query status ${response.status}`, + }); } - const payload = yield* response.json; + const payload = yield* response.json.pipe(Effect.mapError(transportFailure)); return yield* parseFirstCname(payload, args.customHostname); }).pipe( Effect.timeout("10 seconds"), - Effect.mapError( - (cause) => - new LegacyDomainsCnameError({ - message: `expected custom hostname '${args.customHostname}' to have a CNAME record pointing to your project at '${expected}', but it failed to resolve: ${formatCnameCause(cause)}`, - }), - ), + Effect.mapError((cause) => { + const failure: LegacyCnameFailure = + typeof cause === "object" && cause !== null && "transport" in cause + ? cause + : transportFailure(cause); + return new LegacyDomainsCnameError({ + message: `expected custom hostname '${args.customHostname}' to have a CNAME record pointing to your project at '${expected}', but it failed to resolve: ${failure.detail}`, + transport: failure.transport, + }); + }), ); if (resolved !== expected) { diff --git a/apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts b/apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts index c6304af928..fdbc439de6 100644 --- a/apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts +++ b/apps/cli/src/legacy/commands/domains/domains.cname.unit.test.ts @@ -33,11 +33,12 @@ describe("parseFirstCname", () => { expect(Exit.isFailure(exit)).toBe(true); }); - it("fails with a locate error when no CNAME answer is present", () => { - const error = Effect.runSync( + it("fails with a non-transport locate failure when no CNAME answer is present", () => { + const failure = Effect.runSync( Effect.flip(parseFirstCname({ Answer: [{ type: 1, data: "1.2.3.4" }] }, "host.example.com")), ); - expect(error.message).toContain( + expect(failure.transport).toBe(false); + expect(failure.detail).toContain( "failed to locate appropriate CNAME record for host.example.com", ); }); diff --git a/apps/cli/src/legacy/commands/domains/domains.errors.ts b/apps/cli/src/legacy/commands/domains/domains.errors.ts index 11b68b3b65..d67526fd78 100644 --- a/apps/cli/src/legacy/commands/domains/domains.errors.ts +++ b/apps/cli/src/legacy/commands/domains/domains.errors.ts @@ -1,28 +1,45 @@ import { Data } from "effect"; import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; /** * Transport-level failure talking to the Management API custom-hostname * endpoints. Mirrors Go's `errors.Errorf("failed to custom hostname: %w", err)` * (`apps/cli-go/internal/hostnames/*`). */ -class LegacyDomainsNetworkError extends Data.TaggedError("LegacyDomainsNetworkError")<{ +export class LegacyDomainsNetworkError extends Data.TaggedError("LegacyDomainsNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** * The custom-hostname endpoint returned a status the Go CLI does not treat as * success (201 for create/reverify/activate, 200 for get/delete). Mirrors Go's * `errors.Errorf("unexpected hostname status %d: %s", code, body)`. */ -class LegacyDomainsUnexpectedStatusError extends Data.TaggedError( +export class LegacyDomainsUnexpectedStatusError extends Data.TaggedError( "LegacyDomainsUnexpectedStatusError", )<{ readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** * The CNAME pre-check in `domains create` failed — either the DNS lookup did @@ -31,7 +48,20 @@ class LegacyDomainsUnexpectedStatusError extends Data.TaggedError( */ export class LegacyDomainsCnameError extends Data.TaggedError("LegacyDomainsCnameError")<{ readonly message: string; -}> {} + /** + * Set when the DNS-over-HTTPS resolver call itself failed (timeout, + * non-200, or fetch failure against the 1.1.1.1 resolver) rather than the + * CNAME being missing or pointing at the wrong host. + */ + readonly transport?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.transport === true) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + return actionability.invalidConfig; + } +} /** * Build the network/status error mapper for a custom-hostname subcommand. The diff --git a/apps/cli/src/legacy/commands/encryption/encryption.errors.ts b/apps/cli/src/legacy/commands/encryption/encryption.errors.ts index cf57170f97..db959f75f8 100644 --- a/apps/cli/src/legacy/commands/encryption/encryption.errors.ts +++ b/apps/cli/src/legacy/commands/encryption/encryption.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; /** @@ -7,22 +13,33 @@ import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; * Mirrors Go's `errors.Errorf("failed to pgsodium config: %w", err)` * (`apps/cli-go/internal/encryption/{get,update}`). */ -class LegacyEncryptionNetworkError extends Data.TaggedError("LegacyEncryptionNetworkError")<{ +export class LegacyEncryptionNetworkError extends Data.TaggedError("LegacyEncryptionNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** * The pgsodium endpoint returned a status the Go CLI does not treat as success * (it only accepts `JSON200`). Mirrors Go's * `errors.Errorf("unexpected pgsodium config status %d: %s", code, body)`. */ -class LegacyEncryptionUnexpectedStatusError extends Data.TaggedError( +export class LegacyEncryptionUnexpectedStatusError extends Data.TaggedError( "LegacyEncryptionUnexpectedStatusError", )<{ readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** * Build the network/status error mapper for an encryption subcommand. Go uses diff --git a/apps/cli/src/legacy/commands/functions/list/list.errors.ts b/apps/cli/src/legacy/commands/functions/list/list.errors.ts index b4348baf5c..47c7799fa4 100644 --- a/apps/cli/src/legacy/commands/functions/list/list.errors.ts +++ b/apps/cli/src/legacy/commands/functions/list/list.errors.ts @@ -1,10 +1,23 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; export class LegacyFunctionsListNetworkError extends Data.TaggedError( "LegacyFunctionsListNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyFunctionsListUnexpectedStatusError extends Data.TaggedError( "LegacyFunctionsListUnexpectedStatusError", @@ -12,10 +25,18 @@ export class LegacyFunctionsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyFunctionsEnvNotSupportedError extends Data.TaggedError( "LegacyFunctionsEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} diff --git a/apps/cli/src/legacy/commands/functions/new/new.errors.ts b/apps/cli/src/legacy/commands/functions/new/new.errors.ts index ac34daf516..2d5916b53c 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.errors.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.errors.ts @@ -1,11 +1,20 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; export class LegacyFunctionsNewInvalidSlugError extends Data.TaggedError( "LegacyFunctionsNewInvalidSlugError", )<{ readonly message: string; readonly detail: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyFunctionsNewFileExistsError extends Data.TaggedError( "LegacyFunctionsNewFileExistsError", @@ -13,12 +22,20 @@ export class LegacyFunctionsNewFileExistsError extends Data.TaggedError( readonly path: string; readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyFunctionsNewWriteError extends Data.TaggedError("LegacyFunctionsNewWriteError")<{ readonly path: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * Maps an arbitrary thrown cause from a filesystem write to a typed diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.errors.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.errors.ts index cf53a22656..10e806e398 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.errors.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.errors.ts @@ -1,35 +1,64 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; export class LegacyGenSigningKeyConfigParseError extends Data.TaggedError( "LegacyGenSigningKeyConfigParseError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} export class LegacyGenSigningKeyGenerateError extends Data.TaggedError( "LegacyGenSigningKeyGenerateError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.internalPanic; + } +} export class LegacyGenSigningKeyReadError extends Data.TaggedError("LegacyGenSigningKeyReadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} export class LegacyGenSigningKeyDecodeError extends Data.TaggedError( "LegacyGenSigningKeyDecodeError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} export class LegacyGenSigningKeyWriteError extends Data.TaggedError( "LegacyGenSigningKeyWriteError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} export class LegacyGenSigningKeyCancelledError extends Data.TaggedError( "LegacyGenSigningKeyCancelledError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/legacy/commands/gen/types/types.errors.ts b/apps/cli/src/legacy/commands/gen/types/types.errors.ts index 5dde3f3fef..c2976d01eb 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.errors.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.errors.ts @@ -1,8 +1,21 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../../shared/telemetry/error-actionability.ts"; export class LegacyGenTypesNetworkError extends Data.TaggedError("LegacyGenTypesNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyGenTypesUnexpectedStatusError extends Data.TaggedError( "LegacyGenTypesUnexpectedStatusError", @@ -10,16 +23,28 @@ export class LegacyGenTypesUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyInvalidGenTypesDurationError extends Data.TaggedError( "LegacyInvalidGenTypesDurationError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyInvalidGenTypesDatabaseUrlError extends Data.TaggedError( "LegacyInvalidGenTypesDatabaseUrlError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts index 345ad9185a..665fe22b13 100644 --- a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts @@ -3,6 +3,11 @@ import { Data, Effect, Option } from "effect"; import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; import type { LegacyResolvedDbConfig } from "../../../shared/legacy-db-config.types.ts"; @@ -60,7 +65,11 @@ export interface LegacyInspectQuerySpec { */ export class LegacyInspectMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyInspectMutuallyExclusiveFlagsError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // --------------------------------------------------------------------------- // Cell formatters — pure, exported, unit-tested. Each reproduces a Go `fmt` diff --git a/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts b/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts index 38eece5203..7cc7dc37d4 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.csvq.ts @@ -1,4 +1,9 @@ import { Option } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * A bounded, hand-written evaluator for the subset of the csvq SQL dialect that @@ -48,6 +53,10 @@ import { Option } from "effect"; /** Thrown for grammar or evaluation outside the supported csvq subset. */ export class LegacyInspectCsvqError extends Error { override readonly name = "LegacyInspectCsvqError"; + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.impossibleState; + } } // --------------------------------------------------------------------------- diff --git a/apps/cli/src/legacy/commands/inspect/report/report.errors.ts b/apps/cli/src/legacy/commands/inspect/report/report.errors.ts index 57ef7a82e4..32f043e77c 100644 --- a/apps/cli/src/legacy/commands/inspect/report/report.errors.ts +++ b/apps/cli/src/legacy/commands/inspect/report/report.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Creating the dated `//` directory failed. Mirrors Go's @@ -7,7 +12,11 @@ import { Data } from "effect"; */ export class LegacyInspectReportMkdirError extends Data.TaggedError( "LegacyInspectReportMkdirError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * Writing one of the report CSV files failed. Mirrors Go's `copyToCSV` @@ -18,4 +27,8 @@ export class LegacyInspectReportMkdirError extends Data.TaggedError( */ export class LegacyInspectReportWriteError extends Data.TaggedError( "LegacyInspectReportWriteError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/link/link.errors.ts b/apps/cli/src/legacy/commands/link/link.errors.ts index 504965d46a..e856124f0f 100644 --- a/apps/cli/src/legacy/commands/link/link.errors.ts +++ b/apps/cli/src/legacy/commands/link/link.errors.ts @@ -1,11 +1,24 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + CliErrorCategory, + CliErrorKind, + CliSuggestionType, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; /** Transport failure while fetching `GET /v1/projects/{ref}`. */ export class LegacyLinkProjectStatusNetworkError extends Data.TaggedError( "LegacyLinkProjectStatusNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} /** * `GET /v1/projects/{ref}` returned a non-200, non-404 status. Byte-matches Go's @@ -15,7 +28,11 @@ export class LegacyLinkProjectStatusError extends Data.TaggedError("LegacyLinkPr readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** * The remote project is paused (`status == INACTIVE`). Message `"project is paused"` @@ -25,14 +42,32 @@ export class LegacyLinkProjectStatusError extends Data.TaggedError("LegacyLinkPr export class LegacyProjectPausedError extends Data.TaggedError("LegacyProjectPausedError")<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The rendered remediation is "unpause it from the Supabase dashboard" — + // remote project state, not local config and not an entitlement failure. + return { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.ProjectPaused, + has_suggestion: true, + suggestion_type: CliSuggestionType.OpenDashboard, + }; + } +} /** Transport failure while fetching `GET /v1/projects/{ref}/api-keys`. */ export class LegacyLinkApiKeysNetworkError extends Data.TaggedError( "LegacyLinkApiKeysNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** * `GET /v1/projects/{ref}/api-keys` returned a non-200 status. Byte-matches Go's @@ -43,7 +78,13 @@ export class LegacyLinkAuthTokenError extends Data.TaggedError("LegacyLinkAuthTo readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The shared mapper wraps any non-200 in this tag; only a 401 is an auth + // failure the user fixes by re-logging in. + return statusCodeActionability(this.status); + } +} /** * The api-keys response contained no usable anon/service-role key. Byte-matches @@ -51,4 +92,8 @@ export class LegacyLinkAuthTokenError extends Data.TaggedError("LegacyLinkAuthTo */ export class LegacyLinkMissingKeyError extends Data.TaggedError("LegacyLinkMissingKeyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/login/login.errors.ts b/apps/cli/src/legacy/commands/login/login.errors.ts index 65b9c5aab1..e964d6bc9f 100644 --- a/apps/cli/src/legacy/commands/login/login.errors.ts +++ b/apps/cli/src/legacy/commands/login/login.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + /** * Go's `ErrMissingToken` (`apps/cli-go/cmd/login.go:16`). Go Aqua-styles the * `--token` / `SUPABASE_ACCESS_TOKEN` substrings, but the legacy port renders @@ -12,12 +18,20 @@ export const LEGACY_LOGIN_MISSING_TOKEN_MESSAGE = /** Token-path save failure — Go's `cannot save provided token: %w` (`login.go:171`). */ export class LegacyLoginSaveTokenError extends Data.TaggedError("LegacyLoginSaveTokenError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** Non-TTY environment with no token supplied (`login.go:34-35`). */ export class LegacyLoginMissingTokenError extends Data.TaggedError("LegacyLoginMissingTokenError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authToken; + } +} /** * A single login-session poll/parse failure. Carries the underlying message so @@ -27,19 +41,54 @@ export class LegacyLoginMissingTokenError extends Data.TaggedError("LegacyLoginM */ export class LegacyLoginVerificationError extends Data.TaggedError("LegacyLoginVerificationError")<{ readonly message: string; -}> {} + /** HTTP status of a non-200 poll response, when one was received. */ + readonly statusCode?: number; + /** Set when the poll failed at the transport layer (connection/timeout). */ + readonly network?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} -/** All verification retries exhausted (`login.go:214-216`). */ +/** + * All verification retries exhausted (`login.go:214-216`). Carries the LAST + * poll failure's discriminant so classification distinguishes "the user never + * completed the browser flow" (the endpoint keeps returning a pending 4xx, or + * no signal) from a genuine platform problem (5xx / transport). See the Go + * poll protocol: `pollForAccessToken` treats every non-200 as a retryable + * error (`login.go:132-157`, `pkg/fetcher/http.go:102-113`). + */ export class LegacyLoginFailedError extends Data.TaggedError("LegacyLoginFailedError")<{ readonly message: string; -}> {} + readonly statusCode?: number; + readonly network?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.network === true) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + if (this.statusCode !== undefined && this.statusCode >= 500) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_status" }; + } + return actionability.authLogin; + } +} /** ECDH / AES-GCM decryption failure — Go's `cannot decrypt access token` (`login.go:47`). */ export class LegacyLoginDecryptError extends Data.TaggedError("LegacyLoginDecryptError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} /** ECDH keypair generation failure — Go's `cannot generate crypto keys` (`login.go:66`). */ export class LegacyLoginCryptoError extends Data.TaggedError("LegacyLoginCryptoError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.internalPanic; + } +} diff --git a/apps/cli/src/legacy/commands/logout/logout.errors.ts b/apps/cli/src/legacy/commands/logout/logout.errors.ts index 1f3dd768f6..acedafcd1b 100644 --- a/apps/cli/src/legacy/commands/logout/logout.errors.ts +++ b/apps/cli/src/legacy/commands/logout/logout.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; /** * Raised when the user declines the logout confirmation prompt. Go returns @@ -9,6 +14,10 @@ import { Data } from "effect"; */ export class LegacyLogoutCancelledError extends Data.TaggedError("LegacyLogoutCancelledError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} export const LEGACY_LOGOUT_CANCELLED_MESSAGE = "context canceled"; diff --git a/apps/cli/src/legacy/commands/migration/down/down.errors.ts b/apps/cli/src/legacy/commands/migration/down/down.errors.ts index a243205914..d17e86a0b6 100644 --- a/apps/cli/src/legacy/commands/migration/down/down.errors.ts +++ b/apps/cli/src/legacy/commands/migration/down/down.errors.ts @@ -1,9 +1,18 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** `--last 0`. Byte-matches Go's `--last must be greater than 0` (`down.go:21`). */ export class LegacyMigrationLastZeroError extends Data.TaggedError("LegacyMigrationLastZeroError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--last` >= the number of applied migrations. Byte-matches Go's @@ -15,4 +24,8 @@ export class LegacyMigrationLastTooLargeError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/migration/fetch/fetch.errors.ts b/apps/cli/src/legacy/commands/migration/fetch/fetch.errors.ts index 3cc8289327..6418ad4af0 100644 --- a/apps/cli/src/legacy/commands/migration/fetch/fetch.errors.ts +++ b/apps/cli/src/legacy/commands/migration/fetch/fetch.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * Writing a fetched migration file failed. Byte-matches Go's * `failed to write migration: %w` (`internal/migration/fetch/fetch.go:38`). @@ -8,4 +14,8 @@ export class LegacyMigrationFetchWriteError extends Data.TaggedError( "LegacyMigrationFetchWriteError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/migration/migration.errors.ts b/apps/cli/src/legacy/commands/migration/migration.errors.ts index 09d3f96bb8..af8d0ad977 100644 --- a/apps/cli/src/legacy/commands/migration/migration.errors.ts +++ b/apps/cli/src/legacy/commands/migration/migration.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + /** * Conflicting database-target flags. Reproduces cobra's * `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` error byte-for-byte @@ -10,7 +16,11 @@ export class LegacyMigrationTargetFlagsError extends Data.TaggedError( "LegacyMigrationTargetFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--db-url` combined with `--password`/`-p`. Reproduces cobra's @@ -20,7 +30,11 @@ export class LegacyMigrationPasswordFlagsError extends Data.TaggedError( "LegacyMigrationPasswordFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * A positional version argument is not a valid integer. Byte-matches Go's @@ -30,7 +44,11 @@ export class LegacyMigrationInvalidVersionError extends Data.TaggedError( "LegacyMigrationInvalidVersionError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * No local migration file matched the requested version glob. Byte-matches Go's @@ -41,7 +59,11 @@ export class LegacyMigrationFileNotFoundError extends Data.TaggedError( "LegacyMigrationFileNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * The user declined a confirmation prompt (overwrite / repair-all / revert). @@ -50,4 +72,8 @@ export class LegacyMigrationFileNotFoundError extends Data.TaggedError( */ export class LegacyOperationCanceledError extends Data.TaggedError("LegacyOperationCanceledError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/legacy/commands/migration/new/new.errors.ts b/apps/cli/src/legacy/commands/migration/new/new.errors.ts index a6f8d07cc1..fb866e0e45 100644 --- a/apps/cli/src/legacy/commands/migration/new/new.errors.ts +++ b/apps/cli/src/legacy/commands/migration/new/new.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Creating the migrations directory or writing the new migration file failed. @@ -7,4 +12,8 @@ import { Data } from "effect"; */ export class LegacyMigrationNewWriteError extends Data.TaggedError("LegacyMigrationNewWriteError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts b/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts index 7b85614e33..55e050110d 100644 --- a/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts +++ b/apps/cli/src/legacy/commands/migration/repair/repair.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Applying the repair batch (TRUNCATE / UPSERT / DELETE) failed. Byte-matches @@ -9,4 +14,8 @@ export class LegacyMigrationRepairUpdateError extends Data.TaggedError( "LegacyMigrationRepairUpdateError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} diff --git a/apps/cli/src/legacy/commands/migration/up/up.errors.ts b/apps/cli/src/legacy/commands/migration/up/up.errors.ts index 683c2c8c25..84f37b2297 100644 --- a/apps/cli/src/legacy/commands/migration/up/up.errors.ts +++ b/apps/cli/src/legacy/commands/migration/up/up.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * A remote migration version is not present in the local migrations directory. @@ -10,7 +15,11 @@ export class LegacyMigrationMissingLocalError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} /** * Out-of-order local migrations exist before the last remote migration, and @@ -23,4 +32,8 @@ export class LegacyMigrationMissingRemoteError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.migrationDrift; + } +} diff --git a/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts b/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts index 6e9459a1fb..55e4dd86f0 100644 --- a/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts +++ b/apps/cli/src/legacy/commands/network-bans/network-bans.errors.ts @@ -1,10 +1,24 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; + export class LegacyNetworkBansGetNetworkError extends Data.TaggedError( "LegacyNetworkBansGetNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyNetworkBansGetUnexpectedStatusError extends Data.TaggedError( "LegacyNetworkBansGetUnexpectedStatusError", @@ -12,13 +26,24 @@ export class LegacyNetworkBansGetUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyNetworkBansRemoveNetworkError extends Data.TaggedError( "LegacyNetworkBansRemoveNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyNetworkBansRemoveUnexpectedStatusError extends Data.TaggedError( "LegacyNetworkBansRemoveUnexpectedStatusError", @@ -26,13 +51,21 @@ export class LegacyNetworkBansRemoveUnexpectedStatusError extends Data.TaggedErr readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyNetworkBansEnvNotSupportedError extends Data.TaggedError( "LegacyNetworkBansEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacyNetworkBansInvalidIpError extends Data.TaggedError( "LegacyNetworkBansInvalidIpError", @@ -43,4 +76,8 @@ export class LegacyNetworkBansInvalidIpError extends Data.TaggedError( constructor(args: { readonly input: string }) { super({ input: args.input, message: `invalid IP address: ${args.input}` }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } } diff --git a/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts b/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts index 8e7b0b2bf0..344bf9aa57 100644 --- a/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts +++ b/apps/cli/src/legacy/commands/network-restrictions/network-restrictions.errors.ts @@ -1,10 +1,23 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacyNetworkRestrictionsGetNetworkError extends Data.TaggedError( "LegacyNetworkRestrictionsGetNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyNetworkRestrictionsGetUnexpectedStatusError extends Data.TaggedError( "LegacyNetworkRestrictionsGetUnexpectedStatusError", @@ -12,13 +25,24 @@ export class LegacyNetworkRestrictionsGetUnexpectedStatusError extends Data.Tagg readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyNetworkRestrictionsUpdateNetworkError extends Data.TaggedError( "LegacyNetworkRestrictionsUpdateNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyNetworkRestrictionsUpdateUnexpectedStatusError extends Data.TaggedError( "LegacyNetworkRestrictionsUpdateUnexpectedStatusError", @@ -26,7 +50,11 @@ export class LegacyNetworkRestrictionsUpdateUnexpectedStatusError extends Data.T readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyNetworkRestrictionsInvalidCidrError extends Data.TaggedError( "LegacyNetworkRestrictionsInvalidCidrError", @@ -38,6 +66,10 @@ export class LegacyNetworkRestrictionsInvalidCidrError extends Data.TaggedError( // Verbatim Go string from `apps/cli-go/internal/restrictions/update/update.go:23`. super({ input: args.input, message: `failed to parse IP: ${args.input}` }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } } export class LegacyNetworkRestrictionsPrivateIpError extends Data.TaggedError( @@ -50,4 +82,8 @@ export class LegacyNetworkRestrictionsPrivateIpError extends Data.TaggedError( // Verbatim Go string from `apps/cli-go/internal/restrictions/update/update.go:26`. super({ input: args.input, message: `private IP provided: ${args.input}` }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } } diff --git a/apps/cli/src/legacy/commands/orgs/orgs.errors.ts b/apps/cli/src/legacy/commands/orgs/orgs.errors.ts index 494060d003..a175e9c6ba 100644 --- a/apps/cli/src/legacy/commands/orgs/orgs.errors.ts +++ b/apps/cli/src/legacy/commands/orgs/orgs.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; // --------------------------------------------------------------------------- // HTTP-bound errors — one (Network + UnexpectedStatus) pair per Go errorf site @@ -7,7 +13,14 @@ import { Data } from "effect"; export class LegacyOrgsListNetworkError extends Data.TaggedError("LegacyOrgsListNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyOrgsListUnexpectedStatusError extends Data.TaggedError( "LegacyOrgsListUnexpectedStatusError", @@ -15,11 +28,22 @@ export class LegacyOrgsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyOrgsCreateNetworkError extends Data.TaggedError("LegacyOrgsCreateNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyOrgsCreateUnexpectedStatusError extends Data.TaggedError( "LegacyOrgsCreateUnexpectedStatusError", @@ -27,7 +51,11 @@ export class LegacyOrgsCreateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --------------------------------------------------------------------------- // Pure-path error — `orgs list --output env` is explicitly rejected by the Go @@ -40,4 +68,8 @@ export class LegacyOrgsEnvNotSupportedError extends Data.TaggedError( "LegacyOrgsEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} diff --git a/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts b/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts index a15a8c4c5f..27f4431c16 100644 --- a/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts +++ b/apps/cli/src/legacy/commands/postgres-config/postgres-config.errors.ts @@ -1,10 +1,20 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacyPostgresConfigGetNetworkError extends Data.TaggedError( "LegacyPostgresConfigGetNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyPostgresConfigGetUnexpectedStatusError extends Data.TaggedError( "LegacyPostgresConfigGetUnexpectedStatusError", @@ -12,19 +22,31 @@ export class LegacyPostgresConfigGetUnexpectedStatusError extends Data.TaggedErr readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyPostgresConfigGetUnmarshalError extends Data.TaggedError( "LegacyPostgresConfigGetUnmarshalError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} export class LegacyPostgresConfigUpdateNetworkError extends Data.TaggedError( "LegacyPostgresConfigUpdateNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyPostgresConfigUpdateUnexpectedStatusError extends Data.TaggedError( "LegacyPostgresConfigUpdateUnexpectedStatusError", @@ -32,25 +54,41 @@ export class LegacyPostgresConfigUpdateUnexpectedStatusError extends Data.Tagged readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyPostgresConfigUpdateUnmarshalError extends Data.TaggedError( "LegacyPostgresConfigUpdateUnmarshalError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} export class LegacyPostgresConfigUpdateSerializeError extends Data.TaggedError( "LegacyPostgresConfigUpdateSerializeError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyPostgresConfigDeleteNetworkError extends Data.TaggedError( "LegacyPostgresConfigDeleteNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyPostgresConfigDeleteUnexpectedStatusError extends Data.TaggedError( "LegacyPostgresConfigDeleteUnexpectedStatusError", @@ -58,19 +96,31 @@ export class LegacyPostgresConfigDeleteUnexpectedStatusError extends Data.Tagged readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyPostgresConfigDeleteUnmarshalError extends Data.TaggedError( "LegacyPostgresConfigDeleteUnmarshalError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} export class LegacyPostgresConfigDeleteSerializeError extends Data.TaggedError( "LegacyPostgresConfigDeleteSerializeError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacyPostgresConfigInvalidConfigValueError extends Data.TaggedError( "LegacyPostgresConfigInvalidConfigValueError", @@ -84,4 +134,8 @@ export class LegacyPostgresConfigInvalidConfigValueError extends Data.TaggedErro message: `expected config value in key:value format, received: '${args.input}'`, }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } diff --git a/apps/cli/src/legacy/commands/projects/projects.errors.ts b/apps/cli/src/legacy/commands/projects/projects.errors.ts index 791e931075..54b520954d 100644 --- a/apps/cli/src/legacy/commands/projects/projects.errors.ts +++ b/apps/cli/src/legacy/commands/projects/projects.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; // --------------------------------------------------------------------------- // HTTP-bound errors — one (Network + UnexpectedStatus) pair per Go errorf site. @@ -10,7 +16,11 @@ export class LegacyProjectsListNetworkError extends Data.TaggedError( "LegacyProjectsListNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyProjectsListUnexpectedStatusError extends Data.TaggedError( "LegacyProjectsListUnexpectedStatusError", @@ -18,13 +28,21 @@ export class LegacyProjectsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyProjectsCreateNetworkError extends Data.TaggedError( "LegacyProjectsCreateNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyProjectsCreateUnexpectedStatusError extends Data.TaggedError( "LegacyProjectsCreateUnexpectedStatusError", @@ -32,7 +50,11 @@ export class LegacyProjectsCreateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // Interactive org list fetched by `create` when `--org-id` is omitted // (`create.go:97-105`). @@ -40,7 +62,14 @@ export class LegacyProjectsOrgsListNetworkError extends Data.TaggedError( "LegacyProjectsOrgsListNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyProjectsOrgsListUnexpectedStatusError extends Data.TaggedError( "LegacyProjectsOrgsListUnexpectedStatusError", @@ -48,13 +77,24 @@ export class LegacyProjectsOrgsListUnexpectedStatusError extends Data.TaggedErro readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyProjectsDeleteNetworkError extends Data.TaggedError( "LegacyProjectsDeleteNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyProjectsDeleteUnexpectedStatusError extends Data.TaggedError( "LegacyProjectsDeleteUnexpectedStatusError", @@ -62,20 +102,35 @@ export class LegacyProjectsDeleteUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // 404 branch of `delete.Run` (`delete.go:37-38`): "Project does not exist:". export class LegacyProjectsDeleteNotFoundError extends Data.TaggedError( "LegacyProjectsDeleteNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacyProjectsApiKeysNetworkError extends Data.TaggedError( "LegacyProjectsApiKeysNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyProjectsApiKeysUnexpectedStatusError extends Data.TaggedError( "LegacyProjectsApiKeysUnexpectedStatusError", @@ -83,7 +138,11 @@ export class LegacyProjectsApiKeysUnexpectedStatusError extends Data.TaggedError readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --------------------------------------------------------------------------- // Pure-path errors (validation, prompt-time semantics, user cancellation). @@ -94,7 +153,11 @@ export class LegacyProjectsEnvNotSupportedError extends Data.TaggedError( "LegacyProjectsEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} // Non-interactive `create` missing required params — mirrors Go's PreRunE // marking `--org-id`, `--db-password`, `--region` required + ExactArgs(1) @@ -103,14 +166,22 @@ export class LegacyProjectsCreateMissingArgError extends Data.TaggedError( "LegacyProjectsCreateMissingArgError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // Interactive `create` name prompt returned blank (`create.go:94`). export class LegacyProjectsCreateNameEmptyError extends Data.TaggedError( "LegacyProjectsCreateNameEmptyError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // `delete` non-interactive with no positional ref — mirrors Go's // `cobra.ExactArgs(1)` on a non-TTY (`projects.go:109-113`). @@ -118,7 +189,11 @@ export class LegacyProjectsDeleteRefRequiredError extends Data.TaggedError( "LegacyProjectsDeleteRefRequiredError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // User declined the delete confirmation prompt (`delete.go:24-25`, // `errors.New(context.Canceled)`). @@ -126,4 +201,8 @@ export class LegacyProjectsDeleteCancelledError extends Data.TaggedError( "LegacyProjectsDeleteCancelledError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/legacy/commands/secrets/secrets.errors.ts b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts index 385c0911fe..a75fc5588e 100644 --- a/apps/cli/src/legacy/commands/secrets/secrets.errors.ts +++ b/apps/cli/src/legacy/commands/secrets/secrets.errors.ts @@ -1,5 +1,12 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; + // --------------------------------------------------------------------------- // HTTP-bound errors (network + unexpected-status pairs) // --------------------------------------------------------------------------- @@ -8,7 +15,14 @@ export class LegacySecretsListNetworkError extends Data.TaggedError( "LegacySecretsListNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySecretsListUnexpectedStatusError extends Data.TaggedError( "LegacySecretsListUnexpectedStatusError", @@ -16,11 +30,22 @@ export class LegacySecretsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacySecretsSetNetworkError extends Data.TaggedError("LegacySecretsSetNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySecretsSetUnexpectedStatusError extends Data.TaggedError( "LegacySecretsSetUnexpectedStatusError", @@ -28,13 +53,24 @@ export class LegacySecretsSetUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacySecretsUnsetNetworkError extends Data.TaggedError( "LegacySecretsUnsetNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySecretsUnsetUnexpectedStatusError extends Data.TaggedError( "LegacySecretsUnsetUnexpectedStatusError", @@ -42,7 +78,11 @@ export class LegacySecretsUnsetUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // --------------------------------------------------------------------------- // Pure-path errors (validation, file I/O, user cancellation) @@ -52,33 +92,57 @@ export class LegacySecretsEnvFileOpenError extends Data.TaggedError( "LegacySecretsEnvFileOpenError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacySecretsEnvFileParseError extends Data.TaggedError( "LegacySecretsEnvFileParseError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacyInvalidSecretPairError extends Data.TaggedError("LegacyInvalidSecretPairError")<{ readonly pair: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacySecretsNoArgumentsError extends Data.TaggedError( "LegacySecretsNoArgumentsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacySecretsEnvNotSupportedError extends Data.TaggedError( "LegacySecretsEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacySecretsUnsetCancelledError extends Data.TaggedError( "LegacySecretsUnsetCancelledError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts b/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts index 2f3e9efa6f..cc3e438670 100644 --- a/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts +++ b/apps/cli/src/legacy/commands/seed/buckets/buckets.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; /** * Domain errors specific to `supabase seed buckets`. @@ -18,7 +23,11 @@ import { Data } from "effect"; */ export class LegacySeedConfigLoadError extends Data.TaggedError("LegacySeedConfigLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * Raised when `--local` and `--linked` are both passed, reproducing cobra's @@ -28,4 +37,8 @@ export class LegacySeedMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacySeedMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/services/services.errors.ts b/apps/cli/src/legacy/commands/services/services.errors.ts index 7450caafb2..1eb85785f0 100644 --- a/apps/cli/src/legacy/commands/services/services.errors.ts +++ b/apps/cli/src/legacy/commands/services/services.errors.ts @@ -1,7 +1,16 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacyServicesEnvNotSupportedError extends Data.TaggedError( "LegacyServicesEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} diff --git a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts index 086709b41e..baf35be3e2 100644 --- a/apps/cli/src/legacy/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/download/download.handler.ts @@ -110,6 +110,9 @@ export const legacySnippetsDownload = Effect.fn("legacy.snippets.download")(func (cause) => new LegacySnippetsDownloadNetworkError({ message: `failed to download snippet: ${String(cause)}`, + // 200-response body decode failure — an API-response problem, not + // a transport/network failure. + decode: true, }), ), ); diff --git a/apps/cli/src/legacy/commands/snippets/list/list.handler.ts b/apps/cli/src/legacy/commands/snippets/list/list.handler.ts index fbbf5212dd..6764f3a7b3 100644 --- a/apps/cli/src/legacy/commands/snippets/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/snippets/list/list.handler.ts @@ -128,6 +128,9 @@ export const legacySnippetsList = Effect.fn("legacy.snippets.list")(function* ( (cause) => new LegacySnippetsListNetworkError({ message: `failed to list snippets: ${String(cause)}`, + // 200-response body decode failure — an API-response problem, not + // a transport/network failure. + decode: true, }), ), ); diff --git a/apps/cli/src/legacy/commands/snippets/snippets.errors.ts b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts index e030544b4b..c5cb6a65fc 100644 --- a/apps/cli/src/legacy/commands/snippets/snippets.errors.ts +++ b/apps/cli/src/legacy/commands/snippets/snippets.errors.ts @@ -1,10 +1,23 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacySnippetsListNetworkError extends Data.TaggedError( "LegacySnippetsListNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySnippetsListUnexpectedStatusError extends Data.TaggedError( "LegacySnippetsListUnexpectedStatusError", @@ -12,7 +25,11 @@ export class LegacySnippetsListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // Mirrors Go's `utils.ErrEnvNotSupported` ("--output env is not supported"), // returned from `list.Run` when `OutputFormat.Value == OutputEnv`. @@ -20,19 +37,34 @@ export class LegacySnippetsEnvNotSupportedError extends Data.TaggedError( "LegacySnippetsEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} // Wraps `uuid.Parse` failure in `download.Run`; message preserves Go's // `invalid snippet ID: ` prefix so callers see the same string. export class LegacySnippetsInvalidIdError extends Data.TaggedError("LegacySnippetsInvalidIdError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacySnippetsDownloadNetworkError extends Data.TaggedError( "LegacySnippetsDownloadNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySnippetsDownloadUnexpectedStatusError extends Data.TaggedError( "LegacySnippetsDownloadUnexpectedStatusError", @@ -40,4 +72,13 @@ export class LegacySnippetsDownloadUnexpectedStatusError extends Data.TaggedErro readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // A 404 from `GET /v1/snippets/{id}` means the user-supplied snippet id did + // not match any snippet — user input, not an API failure. + if (this.status === 404) { + return { ...actionability.invalidInput, fingerprint_suffix: "not_found" }; + } + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts index 0ff460b5a1..f8897bc285 100644 --- a/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts +++ b/apps/cli/src/legacy/commands/ssl-enforcement/ssl-enforcement.errors.ts @@ -1,10 +1,23 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacySslEnforcementGetNetworkError extends Data.TaggedError( "LegacySslEnforcementGetNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySslEnforcementGetUnexpectedStatusError extends Data.TaggedError( "LegacySslEnforcementGetUnexpectedStatusError", @@ -12,13 +25,24 @@ export class LegacySslEnforcementGetUnexpectedStatusError extends Data.TaggedErr readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacySslEnforcementUpdateNetworkError extends Data.TaggedError( "LegacySslEnforcementUpdateNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySslEnforcementUpdateUnexpectedStatusError extends Data.TaggedError( "LegacySslEnforcementUpdateUnexpectedStatusError", @@ -26,7 +50,11 @@ export class LegacySslEnforcementUpdateUnexpectedStatusError extends Data.Tagged readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} // Verbatim Go string from `apps/cli-go/internal/ssl_enforcement/update/update.go:27`. export class LegacySslEnforcementNoEnableDisableFlagError extends Data.TaggedError( @@ -37,6 +65,10 @@ export class LegacySslEnforcementNoEnableDisableFlagError extends Data.TaggedErr constructor() { super({ message: "enable/disable not specified" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } // Verbatim cobra string for parity with Go's `MarkFlagsMutuallyExclusive` @@ -53,4 +85,8 @@ export class LegacySslEnforcementMutuallyExclusiveFlagsError extends Data.Tagged "if any flags in the group [enable-db-ssl-enforcement disable-db-ssl-enforcement] are set none of the others can be", }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } diff --git a/apps/cli/src/legacy/commands/sso/add/add.handler.ts b/apps/cli/src/legacy/commands/sso/add/add.handler.ts index e1951e7b6c..a881873c5b 100644 --- a/apps/cli/src/legacy/commands/sso/add/add.handler.ts +++ b/apps/cli/src/legacy/commands/sso/add/add.handler.ts @@ -137,7 +137,7 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy // mapper uses (`mapLegacyHttpError`) so error output stays bounded and // shell-safe — the raw-HTTP path must not skip these defences. const bodyText = sanitizeLegacyErrorBody(rawBody); - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: response.status, @@ -145,7 +145,7 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy yield* creating?.fail() ?? Effect.void; if (response.status === 404) { return yield* Effect.fail( - new LegacySsoAddSamlDisabledError({ message: SAML_DISABLED_MESSAGE }), + new LegacySsoAddSamlDisabledError({ message: SAML_DISABLED_MESSAGE, upgradeSuggested }), ); } return yield* Effect.fail( @@ -153,6 +153,7 @@ export const legacySsoAdd = Effect.fn("legacy.sso.add")(function* (flags: Legacy status: response.status, body: bodyText, message: `Unexpected error adding identity provider: ${bodyText}`, + upgradeSuggested, }), ); } diff --git a/apps/cli/src/legacy/commands/sso/list/list.handler.ts b/apps/cli/src/legacy/commands/sso/list/list.handler.ts index 408e43d23b..803a73eafd 100644 --- a/apps/cli/src/legacy/commands/sso/list/list.handler.ts +++ b/apps/cli/src/legacy/commands/sso/list/list.handler.ts @@ -37,16 +37,24 @@ const handleListError = (ref: string, cause: SupabaseApiError) => Effect.gen(function* () { const mapped = yield* Effect.flip(mapStatusOrNetwork(cause)); if (mapped._tag === "LegacySsoListUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, }); if (mapped.status === 404) { return yield* Effect.fail( - new LegacySsoListSamlDisabledError({ message: SAML_DISABLED_MESSAGE }), + new LegacySsoListSamlDisabledError({ message: SAML_DISABLED_MESSAGE, upgradeSuggested }), ); } + return yield* Effect.fail( + new LegacySsoListUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); } return yield* Effect.fail(mapped); }); diff --git a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts index c3a35f0ef3..f6caf08eed 100644 --- a/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts +++ b/apps/cli/src/legacy/commands/sso/remove/remove.handler.ts @@ -29,7 +29,7 @@ const handleRemoveError = (ref: string, providerId: string, cause: SupabaseApiEr Effect.gen(function* () { const mapped = yield* Effect.flip(mapStatusOrNetwork(cause)); if (mapped._tag === "LegacySsoRemoveUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, @@ -38,9 +38,18 @@ const handleRemoveError = (ref: string, providerId: string, cause: SupabaseApiEr return yield* Effect.fail( new LegacySsoRemoveNotFoundError({ message: `An identity provider with ID ${JSON.stringify(providerId)} could not be found.`, + upgradeSuggested, }), ); } + return yield* Effect.fail( + new LegacySsoRemoveUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); } return yield* Effect.fail(mapped); }); diff --git a/apps/cli/src/legacy/commands/sso/sso.errors.ts b/apps/cli/src/legacy/commands/sso/sso.errors.ts index 55630760c0..d4d6a64d13 100644 --- a/apps/cli/src/legacy/commands/sso/sso.errors.ts +++ b/apps/cli/src/legacy/commands/sso/sso.errors.ts @@ -1,4 +1,29 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + planLimitGatedActionability, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; + +/** + * The SAML feature is entitlement-gated: handlers thread the typed result of + * `legacySuggestUpgrade` (`upgradeSuggested`) into these errors so telemetry + * can distinguish plan-gated failures from ordinary API failures without + * sniffing message text. + */ +const samlDisabledActionability = ( + upgradeSuggested: boolean | undefined, +): CliErrorActionabilityDeclaration => + upgradeSuggested === true + ? planLimitGatedActionability + : { ...actionability.invalidConfig, fingerprint_suffix: "saml_disabled" }; + +const gatedNotFoundActionability = ( + upgradeSuggested: boolean | undefined, +): CliErrorActionabilityDeclaration => + upgradeSuggested === true ? planLimitGatedActionability : actionability.invalidInput; // Shared across show / update / remove: Go's `uuid.Parse` failure. // Message intentionally diverges from Go's verbose `failed to parse provider ID: invalid UUID …` @@ -7,18 +32,34 @@ import { Data } from "effect"; export class LegacySsoInvalidUuidError extends Data.TaggedError("LegacySsoInvalidUuidError")<{ readonly providerId: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} // `sso list` export class LegacySsoListNetworkError extends Data.TaggedError("LegacySsoListNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySsoListSamlDisabledError extends Data.TaggedError( "LegacySsoListSamlDisabledError", )<{ readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return samlDisabledActionability(this.upgradeSuggested); + } +} export class LegacySsoListUnexpectedStatusError extends Data.TaggedError( "LegacySsoListUnexpectedStatusError", @@ -26,18 +67,32 @@ export class LegacySsoListUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} // `sso add` export class LegacySsoAddNetworkError extends Data.TaggedError("LegacySsoAddNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySsoAddSamlDisabledError extends Data.TaggedError( "LegacySsoAddSamlDisabledError", )<{ readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return samlDisabledActionability(this.upgradeSuggested); + } +} export class LegacySsoAddUnexpectedStatusError extends Data.TaggedError( "LegacySsoAddUnexpectedStatusError", @@ -45,51 +100,91 @@ export class LegacySsoAddUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} export class LegacySsoAddMetadataFileError extends Data.TaggedError( "LegacySsoAddMetadataFileError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacySsoAddAttributeMappingFileError extends Data.TaggedError( "LegacySsoAddAttributeMappingFileError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacySsoMutexFlagError extends Data.TaggedError("LegacySsoMutexFlagError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} // Shared across add + update — metadata URL validation. export class LegacySsoMetadataUrlInvalidError extends Data.TaggedError( "LegacySsoMetadataUrlInvalidError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacySsoMetadataUrlNetworkError extends Data.TaggedError( "LegacySsoMetadataUrlNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacySsoMetadataUrlNonUtf8Error extends Data.TaggedError( "LegacySsoMetadataUrlNonUtf8Error", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // `sso show` export class LegacySsoShowNetworkError extends Data.TaggedError("LegacySsoShowNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySsoShowNotFoundError extends Data.TaggedError("LegacySsoShowNotFoundError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} export class LegacySsoShowUnexpectedStatusError extends Data.TaggedError( "LegacySsoShowUnexpectedStatusError", @@ -97,22 +192,42 @@ export class LegacySsoShowUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacySsoShowEnvNotSupportedError extends Data.TaggedError( "LegacySsoShowEnvNotSupportedError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} // `sso update` export class LegacySsoUpdateNetworkError extends Data.TaggedError("LegacySsoUpdateNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySsoUpdateNotFoundError extends Data.TaggedError("LegacySsoUpdateNotFoundError")<{ readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return gatedNotFoundActionability(this.upgradeSuggested); + } +} export class LegacySsoUpdateUnexpectedStatusError extends Data.TaggedError( "LegacySsoUpdateUnexpectedStatusError", @@ -120,28 +235,53 @@ export class LegacySsoUpdateUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} export class LegacySsoUpdateMetadataFileError extends Data.TaggedError( "LegacySsoUpdateMetadataFileError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class LegacySsoUpdateAttributeMappingFileError extends Data.TaggedError( "LegacySsoUpdateAttributeMappingFileError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} // `sso remove` export class LegacySsoRemoveNetworkError extends Data.TaggedError("LegacySsoRemoveNetworkError")<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacySsoRemoveNotFoundError extends Data.TaggedError("LegacySsoRemoveNotFoundError")<{ readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return gatedNotFoundActionability(this.upgradeSuggested); + } +} export class LegacySsoRemoveUnexpectedStatusError extends Data.TaggedError( "LegacySsoRemoveUnexpectedStatusError", @@ -149,4 +289,9 @@ export class LegacySsoRemoveUnexpectedStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} diff --git a/apps/cli/src/legacy/commands/sso/update/update.handler.ts b/apps/cli/src/legacy/commands/sso/update/update.handler.ts index ca86765455..d753a558dd 100644 --- a/apps/cli/src/legacy/commands/sso/update/update.handler.ts +++ b/apps/cli/src/legacy/commands/sso/update/update.handler.ts @@ -52,7 +52,7 @@ const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError Effect.gen(function* () { const mapped = yield* Effect.flip(mapGetStatusOrNetwork(cause)); if (mapped._tag === "LegacySsoUpdateUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: mapped.status, @@ -61,9 +61,18 @@ const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError return yield* Effect.fail( new LegacySsoUpdateNotFoundError({ message: `An identity provider with ID ${JSON.stringify(providerId)} could not be found.`, + upgradeSuggested, }), ); } + return yield* Effect.fail( + new LegacySsoUpdateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); } return yield* Effect.fail(mapped); }); @@ -209,7 +218,7 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( // Cap + sanitise to match `mapLegacyHttpError`'s defences — see add handler // for the rationale; the raw-HTTP path must not bypass these. const bodyText = sanitizeLegacyErrorBody(rawBody); - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "auth.saml_2", statusCode: response.status, @@ -221,6 +230,7 @@ export const legacySsoUpdate = Effect.fn("legacy.sso.update")(function* ( status: response.status, body: bodyText, message: `unexpected error fetching identity provider: ${bodyText}`, + upgradeSuggested, }), ); } diff --git a/apps/cli/src/legacy/commands/status/status.errors.ts b/apps/cli/src/legacy/commands/status/status.errors.ts index 9e72e14fbb..38a8c34727 100644 --- a/apps/cli/src/legacy/commands/status/status.errors.ts +++ b/apps/cli/src/legacy/commands/status/status.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; /** * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a @@ -10,41 +15,76 @@ import { Data } from "effect"; */ export class LegacyStatusWorkdirError extends Data.TaggedError("LegacyStatusWorkdirError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** `loadProjectConfig` rejected `supabase/config.toml` (malformed TOML/JSON). */ export class LegacyStatusConfigLoadError extends Data.TaggedError("LegacyStatusConfigLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** A `--override-name KEY=VALUE` entry did not parse, mirroring `env.EnvironToEnvSet`. */ export class LegacyStatusOverrideParseError extends Data.TaggedError( "LegacyStatusOverrideParseError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} -/** Inspecting the db container failed for a reason other than "not found". */ +/** + * Inspecting the db container failed for a reason other than "not found" — + * except Go's `assertContainerHealthy` never special-cases a missing + * container (see `status.handler.ts`'s step-5 comment): an absent container + * is just another non-zero inspect exit, so the dominant real trigger of this + * error is "the local stack was never started", same fix as + * {@link LegacyStatusDbNotRunningError}. + */ export class LegacyStatusDbInspectError extends Data.TaggedError("LegacyStatusDbInspectError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** The db container is absent or present but not in the `running` state. */ export class LegacyStatusDbNotRunningError extends Data.TaggedError( "LegacyStatusDbNotRunningError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** The db container is running but its Docker health check is not `healthy`. */ export class LegacyStatusDbNotReadyError extends Data.TaggedError("LegacyStatusDbNotReadyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} /** Listing running containers by label failed. */ export class LegacyStatusListError extends Data.TaggedError("LegacyStatusListError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** * `config.toml` resolved to a value `Config.Validate` would reject before status @@ -55,4 +95,8 @@ export class LegacyStatusInvalidConfigError extends Data.TaggedError( "LegacyStatusInvalidConfigError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} diff --git a/apps/cli/src/legacy/commands/stop/stop.errors.ts b/apps/cli/src/legacy/commands/stop/stop.errors.ts index ac8d49db97..6b1f78943d 100644 --- a/apps/cli/src/legacy/commands/stop/stop.errors.ts +++ b/apps/cli/src/legacy/commands/stop/stop.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; /** * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a @@ -10,7 +15,11 @@ import { Data } from "effect"; */ export class LegacyStopWorkdirError extends Data.TaggedError("LegacyStopWorkdirError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `--project-id` and `--all` were both set. Best-effort match of cobra's @@ -23,12 +32,20 @@ export class LegacyStopMutuallyExclusiveError extends Data.TaggedError( "LegacyStopMutuallyExclusiveError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** Loading `config.toml` failed for a reason other than the file being absent (malformed TOML). */ export class LegacyStopConfigLoadError extends Data.TaggedError("LegacyStopConfigLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * Listing containers to stop failed. `stop`-specific wrapper over @@ -37,26 +54,46 @@ export class LegacyStopConfigLoadError extends Data.TaggedError("LegacyStopConfi */ export class LegacyStopListError extends Data.TaggedError("LegacyStopListError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** Stopping one or more containers failed (`DockerRemoveAll`'s `WaitAll` step). */ export class LegacyStopContainerError extends Data.TaggedError("LegacyStopContainerError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** `docker container prune` failed. */ export class LegacyStopContainerPruneError extends Data.TaggedError( "LegacyStopContainerPruneError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** `docker volume prune` failed (only run when `--no-backup`/`--backup=false`). */ export class LegacyStopVolumePruneError extends Data.TaggedError("LegacyStopVolumePruneError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} /** `docker network prune` failed. */ export class LegacyStopNetworkPruneError extends Data.TaggedError("LegacyStopNetworkPruneError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} diff --git a/apps/cli/src/legacy/commands/storage/storage.errors.ts b/apps/cli/src/legacy/commands/storage/storage.errors.ts index af87bd9059..aa8871cd9b 100644 --- a/apps/cli/src/legacy/commands/storage/storage.errors.ts +++ b/apps/cli/src/legacy/commands/storage/storage.errors.ts @@ -1,5 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; import { legacyAqua } from "../../shared/legacy-colors.ts"; /** @@ -21,6 +26,10 @@ export class LegacyStorageInvalidUrlError extends Data.TaggedError("LegacyStorag constructor() { super({ message: "URL must match pattern ss:///bucket/[prefix]" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** @@ -30,7 +39,11 @@ export class LegacyStorageInvalidUrlError extends Data.TaggedError("LegacyStorag */ export class LegacyStorageUrlParseError extends Data.TaggedError("LegacyStorageUrlParseError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * `cp`'s local→local branch (`internal/storage/cp/cp.go:59-60`). Go sets @@ -49,6 +62,10 @@ export class LegacyStorageUnsupportedOperationError extends Data.TaggedError( suggestion: `Run ${legacyAqua("cp -r ")} to copy between local directories.`, }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** `cp`'s remote→remote branch (`internal/storage/cp/cp.go:57`). */ @@ -60,6 +77,10 @@ export class LegacyStorageCopyBetweenBucketsError extends Data.TaggedError( constructor() { super({ message: "Copying between buckets is not supported" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** `mv`'s cross-bucket branch (`internal/storage/mv/mv.go:19,38`). */ @@ -71,6 +92,10 @@ export class LegacyStorageUnsupportedMoveError extends Data.TaggedError( constructor() { super({ message: "Moving between buckets is unsupported" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** `mv`'s both-root branch (`internal/storage/mv/mv.go:20,35`). */ @@ -82,6 +107,10 @@ export class LegacyStorageMissingPathError extends Data.TaggedError( constructor() { super({ message: "You must specify an object path" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** `rm`'s root-arg branch (`internal/storage/rm/rm.go:21,41`). */ @@ -93,6 +122,10 @@ export class LegacyStorageMissingBucketError extends Data.TaggedError( constructor() { super({ message: "You must specify a bucket to delete." }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** `rm`'s directory-without-`-r` branch (`internal/storage/rm/rm.go:22,44,53`). */ @@ -104,6 +137,10 @@ export class LegacyStorageMissingFlagError extends Data.TaggedError( constructor() { super({ message: "You must specify -r flag to delete directories." }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** @@ -119,12 +156,20 @@ export class LegacyStorageObjectNotFoundError extends Data.TaggedError( constructor(path: string) { super({ message: `Object not found: ${path}` }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } } /** `failed to read file:` / `failed to create file:` (`pkg/storage/objects.go`). */ export class LegacyStorageFileError extends Data.TaggedError("LegacyStorageFileError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * Both `--linked` and `--local` set, reproducing cobra's @@ -134,4 +179,8 @@ export class LegacyStorageMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyStorageMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/test/db/db.errors.ts b/apps/cli/src/legacy/commands/test/db/db.errors.ts index 63e3c87d7b..57c6eb1e80 100644 --- a/apps/cli/src/legacy/commands/test/db/db.errors.ts +++ b/apps/cli/src/legacy/commands/test/db/db.errors.ts @@ -1,12 +1,22 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * `create extension if not exists pgtap` failed. Byte-matches Go's * `"failed to enable pgTAP: " + err` (`apps/cli-go/internal/db/test/test.go:70`). */ export class LegacyTestDbEnablePgtapError extends Data.TaggedError("LegacyTestDbEnablePgtapError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** * `pg_prove` exited non-zero (test failures or a container error). Byte-matches @@ -15,7 +25,11 @@ export class LegacyTestDbEnablePgtapError extends Data.TaggedError("LegacyTestDb */ export class LegacyTestDbRunError extends Data.TaggedError("LegacyTestDbRunError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * More than one of `--db-url` / `--linked` / `--local` was set. Reproduces @@ -26,4 +40,8 @@ export class LegacyTestDbMutuallyExclusiveFlagsError extends Data.TaggedError( "LegacyTestDbMutuallyExclusiveFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/legacy/commands/test/db/db.integration.test.ts b/apps/cli/src/legacy/commands/test/db/db.integration.test.ts index 0340f54baa..41c452bc01 100644 --- a/apps/cli/src/legacy/commands/test/db/db.integration.test.ts +++ b/apps/cli/src/legacy/commands/test/db/db.integration.test.ts @@ -111,19 +111,37 @@ function mockDockerRun(opts: { exitCode?: number; runFails?: boolean }) { run: (runOpts) => { lastOpts = runOpts; return opts.runFails === true - ? Effect.fail(new LegacyDockerRunError({ message: "failed to run docker: not found" })) + ? Effect.fail( + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }), + ) : Effect.succeed(opts.exitCode ?? 0); }, runCapture: (runOpts) => { lastOpts = runOpts; return opts.runFails === true - ? Effect.fail(new LegacyDockerRunError({ message: "failed to run docker: not found" })) + ? Effect.fail( + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }), + ) : Effect.succeed({ exitCode: opts.exitCode ?? 0, stdout: new Uint8Array(0), stderr: "" }); }, runStream: (runOpts) => { lastOpts = runOpts; return opts.runFails === true - ? Effect.fail(new LegacyDockerRunError({ message: "failed to run docker: not found" })) + ? Effect.fail( + new LegacyDockerRunError({ + message: "failed to run docker: not found", + reason: "spawn", + daemonDown: false, + }), + ) : Effect.succeed({ exitCode: opts.exitCode ?? 0, stderr: "" }); }, }); diff --git a/apps/cli/src/legacy/commands/test/new/new.errors.ts b/apps/cli/src/legacy/commands/test/new/new.errors.ts index 8cfad19a4a..0edcc72f78 100644 --- a/apps/cli/src/legacy/commands/test/new/new.errors.ts +++ b/apps/cli/src/legacy/commands/test/new/new.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; + /** * The target test file already exists. Byte-matches Go's * `errors.New(path + " already exists.")` (`apps/cli-go/internal/test/new/new.go:26`). @@ -7,7 +13,11 @@ import { Data } from "effect"; export class LegacyTestNewFileExistsError extends Data.TaggedError("LegacyTestNewFileExistsError")<{ readonly path: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Writing the test file failed (e.g. permission denied). Mirrors Go's @@ -16,4 +26,8 @@ export class LegacyTestNewFileExistsError extends Data.TaggedError("LegacyTestNe export class LegacyTestNewWriteError extends Data.TaggedError("LegacyTestNewWriteError")<{ readonly path: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/unlink/unlink.errors.ts b/apps/cli/src/legacy/commands/unlink/unlink.errors.ts index 8c1e9155f1..94e9e94279 100644 --- a/apps/cli/src/legacy/commands/unlink/unlink.errors.ts +++ b/apps/cli/src/legacy/commands/unlink/unlink.errors.ts @@ -1,5 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + /** * Reading `supabase/.temp/project-ref` failed for a reason other than the file * being absent (which maps to `LegacyProjectNotLinkedError`). Byte-matches Go's @@ -7,7 +13,11 @@ import { Data } from "effect"; */ export class LegacyUnlinkRefReadError extends Data.TaggedError("LegacyUnlinkRefReadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} /** * Removing the `supabase/.temp` directory failed. Byte-matches Go's @@ -15,4 +25,8 @@ export class LegacyUnlinkRefReadError extends Data.TaggedError("LegacyUnlinkRefR */ export class LegacyUnlinkTempRemovalError extends Data.TaggedError("LegacyUnlinkTempRemovalError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts index 27877ce2c5..86b0c692dd 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/activate/activate.handler.ts @@ -57,11 +57,19 @@ export const legacyVanitySubdomainsActivate = Effect.fn("legacy.vanity-subdomain // tagged error before deciding whether to suggest an upgrade, then re-fail. const mapped = yield* Effect.flip(mapActivateError(cause)); if (mapped._tag === "LegacyVanitySubdomainsActivateUnexpectedStatusError") { - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "vanity_subdomain", statusCode: mapped.status, }); + return yield* Effect.fail( + new LegacyVanitySubdomainsActivateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); } return yield* Effect.fail(mapped); }), diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts index d17836e693..5dbf711b0c 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -60,12 +60,20 @@ export const legacyVanitySubdomainsCheckAvailability = Effect.fn( if (mapped._tag === "LegacyVanitySubdomainsCheckUnexpectedStatusError") { // Go's check command calls SuggestUpgradeOnError without a following // TrackUpgradeSuggested, so suppress the analytics event for parity. - yield* legacySuggestUpgrade({ + const upgradeSuggested = yield* legacySuggestUpgrade({ projectRef: ref, featureKey: "vanity_subdomain", statusCode: mapped.status, trackAnalytics: false, }); + return yield* Effect.fail( + new LegacyVanitySubdomainsCheckUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }), + ); } return yield* Effect.fail(mapped); }), diff --git a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts index aa849f89ef..9285c24018 100644 --- a/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts +++ b/apps/cli/src/legacy/commands/vanity-subdomains/vanity-subdomains.errors.ts @@ -1,10 +1,23 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; export class LegacyVanitySubdomainsGetNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsGetNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyVanitySubdomainsGetUnexpectedStatusError extends Data.TaggedError( "LegacyVanitySubdomainsGetUnexpectedStatusError", @@ -12,13 +25,24 @@ export class LegacyVanitySubdomainsGetUnexpectedStatusError extends Data.TaggedE readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} export class LegacyVanitySubdomainsCheckNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsCheckNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyVanitySubdomainsCheckUnexpectedStatusError extends Data.TaggedError( "LegacyVanitySubdomainsCheckUnexpectedStatusError", @@ -26,13 +50,25 @@ export class LegacyVanitySubdomainsCheckUnexpectedStatusError extends Data.Tagge readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} export class LegacyVanitySubdomainsActivateNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsActivateNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyVanitySubdomainsActivateUnexpectedStatusError extends Data.TaggedError( "LegacyVanitySubdomainsActivateUnexpectedStatusError", @@ -40,13 +76,25 @@ export class LegacyVanitySubdomainsActivateUnexpectedStatusError extends Data.Ta readonly status: number; readonly body: string; readonly message: string; -}> {} + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} export class LegacyVanitySubdomainsDeleteNetworkError extends Data.TaggedError( "LegacyVanitySubdomainsDeleteNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} export class LegacyVanitySubdomainsDeleteUnexpectedStatusError extends Data.TaggedError( "LegacyVanitySubdomainsDeleteUnexpectedStatusError", @@ -54,4 +102,8 @@ export class LegacyVanitySubdomainsDeleteUnexpectedStatusError extends Data.Tagg readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/src/legacy/config/legacy-profile-file.ts b/apps/cli/src/legacy/config/legacy-profile-file.ts index 680beb4213..fc1485e235 100644 --- a/apps/cli/src/legacy/config/legacy-profile-file.ts +++ b/apps/cli/src/legacy/config/legacy-profile-file.ts @@ -1,5 +1,10 @@ import { Data, Effect, FileSystem, Path } from "effect"; import { resolveSupabaseHome } from "../../shared/config/supabase-home.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Helpers for the persisted profile-name file under the global Supabase home, @@ -29,7 +34,11 @@ export function legacySupabaseHome( * (`apps/cli-go/cmd/login.go:42-46`). */ export class LegacyProfileSaveError extends Data.TaggedError("LegacyProfileSaveError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} export function legacyProfileFilePath( path: Path.Path, diff --git a/apps/cli/src/legacy/config/legacy-project-ref.errors.ts b/apps/cli/src/legacy/config/legacy-project-ref.errors.ts index a8dab2f7dc..81943235df 100644 --- a/apps/cli/src/legacy/config/legacy-project-ref.errors.ts +++ b/apps/cli/src/legacy/config/legacy-project-ref.errors.ts @@ -1,13 +1,27 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + export class LegacyProjectNotLinkedError extends Data.TaggedError("LegacyProjectNotLinkedError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.projectNotLinked; + } +} export class LegacyInvalidProjectRefError extends Data.TaggedError("LegacyInvalidProjectRefError")<{ readonly ref: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} /** * Raised by `resolveForLink` on a non-TTY when neither `--project-ref` nor @@ -19,4 +33,8 @@ export class LegacyProjectRefRequiredError extends Data.TaggedError( "LegacyProjectRefRequiredError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.missingProjectRef; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-config-validate.ts b/apps/cli/src/legacy/shared/legacy-config-validate.ts index f833f95300..7bb299f18a 100644 --- a/apps/cli/src/legacy/shared/legacy-config-validate.ts +++ b/apps/cli/src/legacy/shared/legacy-config-validate.ts @@ -1,5 +1,10 @@ import { isAbsolute, join } from "node:path"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { legacyGoUrlParse } from "./legacy-storage-url.ts"; /** @@ -161,7 +166,11 @@ export function legacyParseGoBool(value: string): boolean | undefined { * `.toThrow("substring")`), so swapping their inline `throw new Error(...)` calls for this class * is a byte-identical, purely internal refactor. */ -export class LegacyConfigValidateError extends Error {} +export class LegacyConfigValidateError extends Error { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** One `[api.tls]` section, post-env-override. See {@link LegacyConfigValidationInput}. */ export interface LegacyApiInput { diff --git a/apps/cli/src/legacy/shared/legacy-container-cli.ts b/apps/cli/src/legacy/shared/legacy-container-cli.ts index 53bfb6e947..a1cc751d08 100644 --- a/apps/cli/src/legacy/shared/legacy-container-cli.ts +++ b/apps/cli/src/legacy/shared/legacy-container-cli.ts @@ -2,6 +2,12 @@ import { Data, Effect, Stream } from "effect"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Container CLIs tried in order: Docker is preferred, Podman is the fallback * for Docker-less hosts (e.g. Podman-only Linux setups). @@ -17,16 +23,21 @@ type Spawner = ChildProcessSpawner["Service"]; /** * Raised when neither `docker` nor `podman` can be spawned at all (e.g. neither * is installed or on `PATH`) — distinct from a spawned process exiting non-zero. - * Not exported: callers never need to match on this type directly, they fold it - * into their own tagged error via {@link legacyDescribeContainerCliFailure} so - * the "no runtime found" root cause survives instead of collapsing into a - * generic "failed to ..." message. + * Callers never need to match on this type directly, they fold it into their + * own tagged error via {@link legacyDescribeContainerCliFailure} so the "no + * runtime found" root cause survives instead of collapsing into a generic + * "failed to ..." message. Exported only so the coverage test can verify its + * own actionability declaration. */ -class LegacyContainerRuntimeNotFoundError extends Data.TaggedError( +export class LegacyContainerRuntimeNotFoundError extends Data.TaggedError( "LegacyContainerRuntimeNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} const RUNTIME_NOT_FOUND_MESSAGE = "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH"; diff --git a/apps/cli/src/legacy/shared/legacy-db-config.errors.ts b/apps/cli/src/legacy/shared/legacy-db-config.errors.ts index d725cbc4c2..93979c11ae 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.errors.ts @@ -1,4 +1,11 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + CliSuggestionType, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; /** * `--db-url` could not be parsed as a Postgres connection string. Mirrors Go's @@ -7,7 +14,11 @@ import { Data } from "effect"; */ export class LegacyDbConfigParseUrlError extends Data.TaggedError("LegacyDbConfigParseUrlError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * `supabase/config.toml` exists but could not be read or parsed. Mirrors Go's @@ -19,12 +30,22 @@ export class LegacyDbConfigParseUrlError extends Data.TaggedError("LegacyDbConfi */ export class LegacyDbConfigLoadError extends Data.TaggedError("LegacyDbConfigLoadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** Transport failure creating a temporary login role (`V1CreateLoginRole`). */ export class LegacyDbConfigLoginRoleNetworkError extends Data.TaggedError( "LegacyDbConfigLoginRoleNetworkError", -)<{ readonly message: string }> {} +)<{ readonly message: string; readonly decode?: boolean }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** Non-201 status creating a temporary login role (`V1CreateLoginRole`). */ export class LegacyDbConfigLoginRoleStatusError extends Data.TaggedError( @@ -33,12 +54,22 @@ export class LegacyDbConfigLoginRoleStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** Transport failure listing network bans (`V1ListAllNetworkBans`). */ export class LegacyDbConfigListBansNetworkError extends Data.TaggedError( "LegacyDbConfigListBansNetworkError", -)<{ readonly message: string }> {} +)<{ readonly message: string; readonly decode?: boolean }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** Non-2xx status listing network bans (`V1ListAllNetworkBans`). */ export class LegacyDbConfigListBansStatusError extends Data.TaggedError( @@ -47,12 +78,22 @@ export class LegacyDbConfigListBansStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** Transport failure removing network bans (`V1DeleteNetworkBans`). */ export class LegacyDbConfigUnbanNetworkError extends Data.TaggedError( "LegacyDbConfigUnbanNetworkError", -)<{ readonly message: string }> {} +)<{ readonly message: string; readonly decode?: boolean }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** Non-2xx status removing network bans (`V1DeleteNetworkBans`). */ export class LegacyDbConfigUnbanStatusError extends Data.TaggedError( @@ -61,7 +102,11 @@ export class LegacyDbConfigUnbanStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} /** * The linked project's direct database host is unreachable (IPv6-only) and no @@ -72,7 +117,18 @@ export class LegacyDbConfigUnbanStatusError extends Data.TaggedError( export class LegacyDbConfigIpv6Error extends Data.TaggedError("LegacyDbConfigIpv6Error")<{ readonly message: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The rendered remediation is "Run supabase link --project-ref to + // setup IPv4 connection", so the suggestion is link-shaped even though the + // category stays db_connection. + return { + ...actionability.dbConnection, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }; + } +} /** * Failed to connect to the linked project as the temporary login role after the @@ -84,7 +140,11 @@ export class LegacyDbConfigConnectTempRoleError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** * The configured pooler connection string does not match the linked project ref @@ -97,4 +157,8 @@ export class LegacyDbConfigPoolerLoginError extends Data.TaggedError( )<{ readonly message: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts b/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts index 49b68bbbce..118c38573a 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Opening a Postgres connection failed. Mirrors Go's `pgx`/`pgconn` connect @@ -9,7 +14,11 @@ import { Data } from "effect"; export class LegacyDbConnectError extends Data.TaggedError("LegacyDbConnectError")<{ readonly message: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} /** * Executing a SQL statement against an open connection failed. Mirrors the Go @@ -24,7 +33,11 @@ export class LegacyDbExecError extends Data.TaggedError("LegacyDbExecError")<{ * missing migration-history table, not an undefined column. */ readonly code?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * A server-side `COPY (...) TO STDOUT` stream failed. Mirrors Go's @@ -37,4 +50,8 @@ export class LegacyDbExecError extends Data.TaggedError("LegacyDbExecError")<{ */ export class LegacyDbCopyError extends Data.TaggedError("LegacyDbCopyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts index f8884a700a..e555ed721a 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-lifecycle.ts @@ -1,6 +1,11 @@ import { Data, Effect, Stream } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { legacyDescribeContainerCliFailure, spawnContainerCli } from "./legacy-container-cli.ts"; type Spawner = ChildProcessSpawner["Service"]; @@ -15,14 +20,34 @@ export class LegacyDockerLifecycleListError extends Data.TaggedError( "LegacyDockerLifecycleListError", )<{ readonly message: string; -}> {} +}> { + // `docker ps`/`docker volume ls` never fail because nothing matches the + // label filter — an empty match is a successful, empty result. Every real + // failure here is therefore a container-runtime problem: neither + // docker/podman could be spawned, or the daemon itself rejected the call. + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dockerNotRunning; + } +} -/** Inspecting a single container's state failed for a reason other than "not found". */ +/** + * Inspecting a single container's state failed for a reason other than "not + * found" — except Go's `assertContainerHealthy` (and this port, matching it, + * see `status.handler.ts`) never special-cases a missing container either: an + * absent container is just another non-zero `docker container inspect` exit, + * which is by far the dominant real trigger of this error (the user hasn't + * run `supabase start` yet) — same fix as the other "stack isn't running" + * errors elsewhere in this codebase. + */ export class LegacyDockerLifecycleInspectError extends Data.TaggedError( "LegacyDockerLifecycleInspectError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.startStack; + } +} function collectByteStream(stream: Stream.Stream) { const decoder = new TextDecoder(); diff --git a/apps/cli/src/legacy/shared/legacy-docker-run.errors.ts b/apps/cli/src/legacy/shared/legacy-docker-run.errors.ts index a2e6ce2a71..6ad69eda21 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-run.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-run.errors.ts @@ -1,6 +1,30 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** Spawning or running the `docker` CLI failed (binary missing, daemon down, non-spawn failure). */ export class LegacyDockerRunError extends Data.TaggedError("LegacyDockerRunError")<{ readonly message: string; -}> {} + /** + * Structured discriminant set at the docker boundary: `spawn` when the + * container runtime could not be executed at all, `pull` when the runtime + * ran but every registry candidate failed to pull. + */ + readonly reason: "spawn" | "pull"; + /** + * For `pull` failures, whether the registry output indicates the daemon + * itself is unreachable — detected where docker's output is produced, so + * consumers never sniff `message` text. + */ + readonly daemonDown: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.reason === "spawn" || this.daemonDown) { + return { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" }; + } + return { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts b/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts index 915268b704..58f7641210 100644 --- a/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-docker-run.layer.ts @@ -1,3 +1,4 @@ +import { isDockerDaemonDownMessage } from "@supabase/stack"; import { Effect, Exit, Layer, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; import { ProcessControl } from "../../shared/runtime/process-control.service.ts"; @@ -48,6 +49,8 @@ export const legacyDockerRunLayer: Layer.Layer< // credential-free message that still points at the likely cause. new LegacyDockerRunError({ message: `failed to run docker. ${LEGACY_SUGGEST_DOCKER_INSTALL}`, + reason: "spawn", + daemonDown: false, }); const concat = (chunks: ReadonlyArray): Uint8Array => { @@ -169,6 +172,8 @@ export const legacyDockerRunLayer: Layer.Layer< return yield* Effect.fail( new LegacyDockerRunError({ message: `failed to pull docker image from all registries: ${failures.join("; ")}`, + reason: "pull", + daemonDown: failures.some(isDockerDaemonDownMessage), }), ); }); @@ -309,6 +314,8 @@ export const legacyDockerRunLayer: Layer.Layer< () => new LegacyDockerRunError({ message: `failed to run docker. ${LEGACY_SUGGEST_DOCKER_INSTALL}`, + reason: "spawn", + daemonDown: false, }), ), ); diff --git a/apps/cli/src/legacy/shared/legacy-drop-objects.ts b/apps/cli/src/legacy/shared/legacy-drop-objects.ts index 523c193a12..7d7de97a93 100644 --- a/apps/cli/src/legacy/shared/legacy-drop-objects.ts +++ b/apps/cli/src/legacy/shared/legacy-drop-objects.ts @@ -1,11 +1,20 @@ import { Data, Effect } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; /** Dropping the user schemas failed (Go's `DropUserSchemas` error). */ export class LegacyMigrationDropError extends Data.TaggedError("LegacyMigrationDropError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** * The embedded `DO $$ ... $$` block from Go's `pkg/migration/queries/drop.sql`, diff --git a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts index 009d602462..bea984e285 100644 --- a/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-edge-runtime-script.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Running a TypeScript program inside the edge-runtime container failed (non-zero @@ -10,4 +15,8 @@ import { Data } from "effect"; */ export class LegacyEdgeRuntimeScriptError extends Data.TaggedError("LegacyEdgeRuntimeScriptError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-ensure-login.ts b/apps/cli/src/legacy/shared/legacy-ensure-login.ts index e94aa87a20..999a3d0767 100644 --- a/apps/cli/src/legacy/shared/legacy-ensure-login.ts +++ b/apps/cli/src/legacy/shared/legacy-ensure-login.ts @@ -137,7 +137,13 @@ export const legacyBrowserLogin = Effect.fnUntraced(function* (opts: LegacyBrows Effect.gen(function* () { const failures = failuresSoFar + 1; if (failures > MAX_LOGIN_RETRIES) { - return yield* Effect.fail(new LegacyLoginFailedError({ message: err.message })); + return yield* Effect.fail( + new LegacyLoginFailedError({ + message: err.message, + statusCode: err.statusCode, + network: err.network, + }), + ); } yield* output.raw(`${err.message}\nRetry (${failures}/${MAX_LOGIN_RETRIES}): `, "stderr"); return yield* verifyWithRetries(failures); diff --git a/apps/cli/src/legacy/shared/legacy-experimental-gate.ts b/apps/cli/src/legacy/shared/legacy-experimental-gate.ts index 325f2e96bb..27ea08fe5c 100644 --- a/apps/cli/src/legacy/shared/legacy-experimental-gate.ts +++ b/apps/cli/src/legacy/shared/legacy-experimental-gate.ts @@ -1,5 +1,10 @@ import { Data, Effect } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { legacyResolveExperimental } from "../../shared/legacy/global-flags.ts"; /** @@ -35,6 +40,10 @@ export class LegacyExperimentalRequiredError extends Data.TaggedError( constructor() { super({ message: "must set the --experimental flag to run this command" }); } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** Fails with {@link LegacyExperimentalRequiredError} unless experimental is enabled. */ diff --git a/apps/cli/src/legacy/shared/legacy-go-output-flag.ts b/apps/cli/src/legacy/shared/legacy-go-output-flag.ts index fc943b7884..48cea50187 100644 --- a/apps/cli/src/legacy/shared/legacy-go-output-flag.ts +++ b/apps/cli/src/legacy/shared/legacy-go-output-flag.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Per-command `--output`/`-o` enums, mirroring Go. Go registers `--output` per @@ -25,7 +30,11 @@ export const LEGACY_QUERY_OUTPUT_FORMATS = ["json", "table", "csv"] as const; */ export class LegacyInvalidOutputFormatError extends Data.TaggedError( "LegacyInvalidOutputFormatError", -)<{ readonly message: string }> {} +)<{ readonly message: string }> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } +} /** Go's `must be one of [ a | b | c ]` (`enum.go:23`, joined with `" | "`). */ export function legacyOutputFormatEnumMessage(allowed: ReadonlyArray): string { diff --git a/apps/cli/src/legacy/shared/legacy-http-errors.ts b/apps/cli/src/legacy/shared/legacy-http-errors.ts index 7639e83f99..760cd3cef6 100644 --- a/apps/cli/src/legacy/shared/legacy-http-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-http-errors.ts @@ -47,7 +47,10 @@ function sanitizeErrorBody(input: string): string { return out; } -export type NetworkErrorFactory = new (args: { readonly message: string }) => E; +export type NetworkErrorFactory = new (args: { + readonly message: string; + readonly decode?: boolean; +}) => E; export type StatusErrorFactory = new (args: { readonly status: number; @@ -92,9 +95,12 @@ export function mapLegacyHttpError(opts: { new opts.networkError({ message: opts.networkMessage(description) }), ); } - // SchemaError or HttpBodyError — treat as transport-level network error. + // SchemaError or HttpBodyError — the server returned a response whose + // body failed schema decoding (a 200 the generated client could not + // parse). This is not a transport failure, so flag `decode` to classify + // it as an API-response problem rather than a network problem. return yield* Effect.fail( - new opts.networkError({ message: opts.networkMessage(String(cause)) }), + new opts.networkError({ message: opts.networkMessage(String(cause)), decode: true }), ); }); } diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index a851d5a670..86b1efa656 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -5,6 +5,11 @@ import { ENV_CAPTURE_REGEX, type ProjectConfig } from "@supabase/config"; import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; import { Schema } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; import { legacySanitizeProjectId } from "./legacy-docker-ids.ts"; import { @@ -116,6 +121,9 @@ export class LegacyInvalidJwtSecretError extends Error { super("Invalid config for auth.jwt_secret. Must be at least 16 characters"); this.name = "LegacyInvalidJwtSecretError"; } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** Go's minimum `auth.jwt_secret` length (`pkg/config/apikeys.go:46`). */ @@ -138,6 +146,9 @@ export class LegacyInvalidPortEnvOverrideError extends Error { super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a port`); this.name = "LegacyInvalidPortEnvOverrideError"; } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** Go's `uint16` port fields' valid range (`pkg/config/db.go:84`, `pkg/config/api.go:29`, etc). */ @@ -234,6 +245,9 @@ export class LegacyInvalidBoolEnvOverrideError extends Error { super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a bool`); this.name = "LegacyInvalidBoolEnvOverrideError"; } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** @@ -289,6 +303,9 @@ export class LegacyInvalidAnalyticsBackendEnvOverrideError extends Error { ); this.name = "LegacyInvalidAnalyticsBackendEnvOverrideError"; } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } /** diff --git a/apps/cli/src/legacy/shared/legacy-login-api.layer.ts b/apps/cli/src/legacy/shared/legacy-login-api.layer.ts index d40f47524b..3113c4c506 100644 --- a/apps/cli/src/legacy/shared/legacy-login-api.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-login-api.layer.ts @@ -38,6 +38,7 @@ export const legacyLoginApiLayer = Layer.effect( return yield* Effect.fail( new LegacyLoginVerificationError({ message: `Error status ${response.status}: ${body}`, + statusCode: response.status, }), ); } @@ -56,6 +57,7 @@ export const legacyLoginApiLayer = Layer.effect( Effect.fail( new LegacyLoginVerificationError({ message: `failed to execute http request: ${cause.message}`, + network: true, }), ), ), @@ -65,6 +67,7 @@ export const legacyLoginApiLayer = Layer.effect( Effect.fail( new LegacyLoginVerificationError({ message: "failed to execute http request: request timed out", + network: true, }), ), }), diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.ts index 3ca0812bdd..d50bda1fbf 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.ts @@ -1,6 +1,11 @@ import { Data, Effect, type FileSystem, type Path } from "effect"; import { Output } from "../../shared/output/output.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { INSERT_MIGRATION_VERSION, @@ -16,7 +21,11 @@ import { legacySplitAndTrim } from "./legacy-sql-split.ts"; */ export class LegacyMigrationApplyError extends Data.TaggedError("LegacyMigrationApplyError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} // Byte order mark (U+FEFF) — stripped from the head of a statement like Go does. const BOM_CODE_POINT = 0xfeff; diff --git a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts index a5039843de..923ecf7dfb 100644 --- a/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-migration-apply.unit.test.ts @@ -7,6 +7,7 @@ import { Data, Effect, Exit, FileSystem, Path } from "effect"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; +import { LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import { legacyApplyMigrationFile, legacyIsPipelineIncompatible, @@ -15,15 +16,13 @@ import { class TestError extends Data.TaggedError("TestError")<{ readonly message: string }> {} -class FakeExecError extends Data.TaggedError("LegacyDbExecError")<{ readonly message: string }> {} - function fakeSession(opts: { failOn?: string } = {}) { const calls: Array<{ kind: "exec" | "query"; sql: string; params?: ReadonlyArray }> = []; const session: LegacyDbSession = { exec: (sql) => { calls.push({ kind: "exec", sql }); return opts.failOn !== undefined && sql.includes(opts.failOn) - ? Effect.fail(new FakeExecError({ message: "exec failed" })) + ? Effect.fail(new LegacyDbExecError({ message: "exec failed" })) : Effect.void; }, query: (sql, params) => { diff --git a/apps/cli/src/legacy/shared/legacy-migration.errors.ts b/apps/cli/src/legacy/shared/legacy-migration.errors.ts index 1cf0340928..64248c821d 100644 --- a/apps/cli/src/legacy/shared/legacy-migration.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-migration.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * Listing or reading migrations failed for a reason other than the directory @@ -13,4 +18,8 @@ import { Data } from "effect"; */ export class LegacyMigrationsReadError extends Data.TaggedError("LegacyMigrationsReadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts index d5dca259f5..175f0fcc80 100644 --- a/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts +++ b/apps/cli/src/legacy/shared/legacy-pgdelta-ssl-probe.service.ts @@ -1,4 +1,9 @@ import { Context, Data, type Effect } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * A live TLS-capability probe for pg-delta SOURCE/TARGET endpoints, mirroring Go's @@ -33,7 +38,11 @@ export interface LegacyPgDeltaSslProbeShape { export class LegacyPgDeltaSslProbeError extends Data.TaggedError("LegacyPgDeltaSslProbeError")<{ readonly message: string; readonly cause?: unknown; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbConnection; + } +} export class LegacyPgDeltaSslProbe extends Context.Service< LegacyPgDeltaSslProbe, diff --git a/apps/cli/src/legacy/shared/legacy-seed.ts b/apps/cli/src/legacy/shared/legacy-seed.ts index dc67437c48..0e4049f6dd 100644 --- a/apps/cli/src/legacy/shared/legacy-seed.ts +++ b/apps/cli/src/legacy/shared/legacy-seed.ts @@ -2,6 +2,11 @@ import { createHash } from "node:crypto"; import { Data, Effect, FileSystem, Path } from "effect"; import { Output } from "../../shared/output/output.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; import { legacyCreateSeedTable, @@ -14,7 +19,11 @@ import { legacySplitAndTrim } from "./legacy-sql-split.ts"; /** Applying a seed file failed (Go's `SeedData` / `ExecBatchWithCache` errors). */ export class LegacyMigrationSeedError extends Data.TaggedError("LegacyMigrationSeedError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** `[db.seed]` config: `enabled` + the (supabase-prefixed) `sql_paths` glob list. */ export interface LegacySeedConfig { diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts index f6f36d126c..563e352ccf 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.errors.ts @@ -1,5 +1,12 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Errors raised while deriving Storage connection credentials, shared by * `seed buckets` and `storage ls/cp/mv/rm`. @@ -11,7 +18,11 @@ import { Data } from "effect"; */ export class LegacyStorageConfigError extends Data.TaggedError("LegacyStorageConfigError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} /** * Raised on `--linked` when the project's api-keys response yields no keys, @@ -23,14 +34,25 @@ export class LegacyStorageMissingApiKeyError extends Data.TaggedError( "LegacyStorageMissingApiKeyError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} /** Transport failure fetching the project's api-keys (`failed to get api keys: `). */ export class LegacyStorageApiKeysNetworkError extends Data.TaggedError( "LegacyStorageApiKeysNetworkError", )<{ readonly message: string; -}> {} + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; + } +} /** * `GET /v1/projects/{ref}/api-keys?reveal=true` returned a non-200 on a @@ -42,4 +64,10 @@ export class LegacyStorageAuthTokenError extends Data.TaggedError("LegacyStorage readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The shared mapper wraps any non-200 in this tag; only a 401 is an auth + // failure the user fixes by re-logging in. + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts b/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts index 1c1d041319..61bf09bbda 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-gateway.errors.ts @@ -1,4 +1,10 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; /** * Errors for the Supabase Storage **service gateway** (Kong), shared by every @@ -17,7 +23,11 @@ export class LegacyStorageGatewayNetworkError extends Data.TaggedError( "LegacyStorageGatewayNetworkError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class LegacyStorageGatewayStatusError extends Data.TaggedError( "LegacyStorageGatewayStatusError", @@ -25,7 +35,17 @@ export class LegacyStorageGatewayStatusError extends Data.TaggedError( readonly status: number; readonly body: string; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + // The tenant Storage gateway is not the Management API: a 401/403 here + // means stale local service keys, which `supabase login` cannot fix, so + // the Management-API auth/permission policy must not apply. + if (this.status === 401 || this.status === 403) { + return { ...actionability.apiStatus, fingerprint_suffix: "gateway_auth" }; + } + return statusCodeActionability(this.status); + } +} export type LegacyStorageGatewayError = | LegacyStorageGatewayNetworkError diff --git a/apps/cli/src/legacy/shared/legacy-storage-url.ts b/apps/cli/src/legacy/shared/legacy-storage-url.ts index 0810c61872..f27d150789 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-url.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-url.ts @@ -1,3 +1,9 @@ +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Storage URL parsing, ported 1:1 from Go's `internal/storage/client/scheme.go` * plus the slices of `net/url` that `url.Parse` exercises for the `ss://` scheme. @@ -32,6 +38,10 @@ export class LegacyGoUrlParseError extends Error { super(`parse "${rawURL}": ${inner}`); this.name = "LegacyGoUrlParseError"; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } /** @@ -45,6 +55,10 @@ export class LegacyStorageUrlPatternError extends Error { super(LEGACY_STORAGE_INVALID_URL_MESSAGE); this.name = "LegacyStorageUrlPatternError"; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } export interface LegacyGoUrl { diff --git a/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts b/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts index dafb3df022..844e8518d5 100644 --- a/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts +++ b/apps/cli/src/legacy/shared/legacy-string-slice-flag.ts @@ -1,3 +1,9 @@ +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Parses a pflag `StringSliceVar` flag: CSV-splits each occurrence via * `encoding/csv` and accumulates across repeats, matching `readAsCSV` in @@ -20,6 +26,10 @@ export class LegacyStringSliceFlagParseError extends Error { this.value = value; this.detail = detail; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidInput; + } } /** diff --git a/apps/cli/src/legacy/shared/legacy-temp-paths.ts b/apps/cli/src/legacy/shared/legacy-temp-paths.ts index aaa14167e9..3fd8ad49ca 100644 --- a/apps/cli/src/legacy/shared/legacy-temp-paths.ts +++ b/apps/cli/src/legacy/shared/legacy-temp-paths.ts @@ -1,4 +1,9 @@ import { Data, Effect, FileSystem, Option, type Path } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; /** * A real failure reading `/supabase/.temp/project-ref` (e.g. the path is a @@ -9,7 +14,11 @@ import { Data, Effect, FileSystem, Option, type Path } from "effect"; */ export class LegacyProjectRefReadError extends Data.TaggedError("LegacyProjectRefReadError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.relinkProject; + } +} /** * Absolute paths to the files the Go CLI writes under `/supabase/.temp/`. diff --git a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts index 0466e726ec..eb405a049d 100644 --- a/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts +++ b/apps/cli/src/legacy/shared/legacy-upgrade-suggest.ts @@ -35,6 +35,9 @@ function readString(obj: unknown, key: string): string { * telemetry event with `{feature_key, org_slug}` properties. * * Never fails the caller; lookup errors swallow into a no-op suggestion. + * Returns whether the feature was confirmed plan-gated, so callers can thread + * the typed result into their error (`upgradeSuggested`) for telemetry + * classification. * * Bypasses the typed Management API client to GET `/v1/projects/{ref}` and * `/v1/organizations/{slug}/entitlements` directly via `HttpClient`. The @@ -58,7 +61,7 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { readonly trackAnalytics?: boolean; }) { if (opts.statusCode < 400 || opts.statusCode >= 500) { - return; + return false; } const output = yield* Output; @@ -78,15 +81,15 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); const projectResp = yield* httpClient.execute(projectReq).pipe(Effect.option); if (projectResp._tag === "None" || projectResp.value.status !== 200) { - return; + return false; } const projectBody = yield* projectResp.value.json.pipe(Effect.option); if (projectBody._tag === "None") { - return; + return false; } const orgSlug = readString(projectBody.value, "organization_slug"); if (orgSlug.length === 0) { - return; + return false; } const entReq = HttpClientRequest.get( @@ -94,15 +97,15 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { ).pipe(authHeader, HttpClientRequest.setHeader("User-Agent", cliConfig.userAgent)); const entResp = yield* httpClient.execute(entReq).pipe(Effect.option); if (entResp._tag === "None" || entResp.value.status !== 200) { - return; + return false; } const entBody = yield* entResp.value.json.pipe(Effect.option); if (entBody._tag === "None") { - return; + return false; } const entitlements = (entBody.value as { entitlements?: unknown }).entitlements; if (!Array.isArray(entitlements)) { - return; + return false; } const gated = entitlements.some((entry: unknown) => { @@ -114,7 +117,7 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { return key === opts.featureKey && hasAccess === false; }); if (!gated) { - return; + return false; } const url = legacyBillingUrl(cliConfig.profile, orgSlug); @@ -130,4 +133,6 @@ export const legacySuggestUpgrade = Effect.fnUntraced(function* (opts: { [PropOrgSlug]: orgSlug, }); } + + return true; }); diff --git a/apps/cli/src/legacy/shared/legacy-vault.ts b/apps/cli/src/legacy/shared/legacy-vault.ts index 16534f8227..b45128e7f3 100644 --- a/apps/cli/src/legacy/shared/legacy-vault.ts +++ b/apps/cli/src/legacy/shared/legacy-vault.ts @@ -1,12 +1,21 @@ import { Data, Effect } from "effect"; import { Output } from "../../shared/output/output.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import type { LegacyDbSession } from "./legacy-db-connection.service.ts"; /** Reading or updating `vault.secrets` failed (Go's `UpsertVaultSecrets` errors). */ export class LegacyMigrationVaultError extends Data.TaggedError("LegacyMigrationVaultError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} /** A resolved `[db.vault]` secret. `resolved` mirrors Go's `len(SHA256) > 0` gate. */ export interface LegacyVaultSecret { diff --git a/apps/cli/src/legacy/shared/legacy-workdir-validation.ts b/apps/cli/src/legacy/shared/legacy-workdir-validation.ts index 40e8a5976f..6731c06923 100644 --- a/apps/cli/src/legacy/shared/legacy-workdir-validation.ts +++ b/apps/cli/src/legacy/shared/legacy-workdir-validation.ts @@ -1,13 +1,27 @@ import { Data, Effect, FileSystem } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; + /** * Raised by {@link legacyValidateWorkdirIsDirectory} when the target path * doesn't exist or isn't a directory. Callers map this into their own - * command-specific error type. + * command-specific error type. Only reachable when the user explicitly set + * `--workdir`/`SUPABASE_WORKDIR` to a bad path — the default walk-up + * resolution can never fail this check (see the doc comment on + * {@link legacyValidateWorkdirIsDirectory} below) — so the fix is always + * "pass a different `--workdir`/`SUPABASE_WORKDIR`". */ export class LegacyWorkdirValidationError extends Data.TaggedError("LegacyWorkdirValidationError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} /** * Validates that `workdir` exists and is a directory, the way Go's diff --git a/apps/cli/src/next/auth/api.layer.ts b/apps/cli/src/next/auth/api.layer.ts index 5e37fa2bc1..fc2eb1627f 100644 --- a/apps/cli/src/next/auth/api.layer.ts +++ b/apps/cli/src/next/auth/api.layer.ts @@ -10,18 +10,42 @@ import { import { ApiError } from "./errors.ts"; import { Api, type LoginSessionResponse } from "./api.service.ts"; -function mapHttpClientError( - error: HttpClientError.HttpClientError, -): Effect.Effect { - if (error.response !== undefined) { - return Effect.fail( - new ApiError({ - statusCode: error.response.status, - detail: `${error.response.status} ${error.message}`, - }), - ); +// HttpClientError reasons that mean the response arrived but its body could not +// be decoded (including a 2xx whose body isn't valid JSON). These are API +// response problems, not transport ones, so they classify by `decode` rather +// than a status code. +const BODY_DECODE_REASONS = new Set(["DecodeError", "EmptyBodyError"]); + +/** + * Maps any fetcher failure to an {@link ApiError}, preserving the classification + * signal: + * - a received status → `statusCode` (transport error → status-less, classified + * as network); + * - a body/schema decode failure — whether an `HttpClientError` decode reason or + * a non-`HttpClientError` thrown while decoding — → `decode: true`, classified + * as an API response problem instead of network. + */ +function mapToApiError(error: unknown): Effect.Effect { + if (HttpClientError.isHttpClientError(error)) { + if (BODY_DECODE_REASONS.has(error.reason._tag)) { + return Effect.fail(new ApiError({ detail: error.message, decode: true })); + } + if (error.response !== undefined) { + return Effect.fail( + new ApiError({ + statusCode: error.response.status, + detail: `${error.response.status} ${error.message}`, + }), + ); + } + return Effect.fail(new ApiError({ detail: error.message })); } - return Effect.fail(new ApiError({ detail: error.message })); + return Effect.fail( + new ApiError({ + detail: error instanceof Error ? error.message : String(error), + decode: true, + }), + ); } export const makeApi = Effect.gen(function* () { @@ -36,7 +60,7 @@ export const makeApi = Effect.gen(function* () { const response = yield* httpClient.execute(HttpClientRequest.get(url)); return (yield* response.json) as LoginSessionResponse; }, - (effect) => effect.pipe(Effect.catch(mapHttpClientError)), + (effect) => effect.pipe(Effect.catch(mapToApiError)), ), fetchProfile: Effect.fnUntraced( function* (apiUrl, accessToken) { @@ -47,19 +71,7 @@ export const makeApi = Effect.gen(function* () { }).pipe(Effect.provide(httpClientLayer)); return yield* api.v1.getProfile(); }, - (effect) => - effect.pipe( - Effect.catch((error) => { - if (HttpClientError.isHttpClientError(error)) { - return mapHttpClientError(error); - } - return Effect.fail( - new ApiError({ - detail: error instanceof Error ? error.message : String(error), - }), - ); - }), - ), + (effect) => effect.pipe(Effect.catch(mapToApiError)), ), }); }); diff --git a/apps/cli/src/next/auth/errors.ts b/apps/cli/src/next/auth/errors.ts index 246cf1368f..34625dac5c 100644 --- a/apps/cli/src/next/auth/errors.ts +++ b/apps/cli/src/next/auth/errors.ts @@ -1,25 +1,58 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../shared/telemetry/error-actionability.ts"; -function CliError(tag: Tag) { - return class extends Data.TaggedError(tag)<{ - readonly detail: string; - readonly suggestion: string; - }> { - override get message() { - return `${this.detail}\n Suggestion: ${this.suggestion}`; - } - }; +export class InvalidTokenError extends Data.TaggedError("InvalidTokenError")<{ + readonly detail: string; + readonly suggestion: string; + /** + * Where the malformed token came from. Direct-input tokens (`--token` flag, + * `SUPABASE_ACCESS_TOKEN`, piped stdin) cannot be fixed by `supabase login`, + * so their remediation is to correct that input. A token from the browser + * flow (no source) is fixable by logging in again. + */ + readonly source?: "env" | "flag" | "stdin"; +}> { + override get message() { + return `${this.detail}\n Suggestion: ${this.suggestion}`; + } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return this.source === undefined ? actionability.authLogin : actionability.authToken; + } } -export class InvalidTokenError extends CliError("InvalidTokenError") {} - export class ApiError extends Data.TaggedError("ApiError")<{ readonly statusCode?: number; readonly detail: string; -}> {} + /** + * Set when this error represents a body/schema decode failure on an + * otherwise-successful response (no status code to classify by), rather + * than a transport failure — so it classifies as an API response problem + * instead of a network problem. + */ + readonly decode?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.statusCode !== undefined) { + return statusCodeActionability(this.statusCode); + } + if (this.decode === true) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; + } + return statusCodeActionability(undefined); + } +} export class PlatformAuthRequiredError extends Data.TaggedError("PlatformAuthRequiredError")<{ readonly message: string; readonly detail?: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} diff --git a/apps/cli/src/next/auth/token.ts b/apps/cli/src/next/auth/token.ts index 631a8aef08..00f959149d 100644 --- a/apps/cli/src/next/auth/token.ts +++ b/apps/cli/src/next/auth/token.ts @@ -3,11 +3,15 @@ import { InvalidTokenError } from "./errors.ts"; const TOKEN_PATTERN = /^sbp_(oauth_)?[a-f0-9]{40}$/; -export const validateToken = Effect.fnUntraced(function* (token: string) { +export const validateToken = Effect.fnUntraced(function* ( + token: string, + source?: "env" | "flag" | "stdin", +) { if (!TOKEN_PATTERN.test(token)) { return yield* new InvalidTokenError({ detail: "Invalid access token format", suggestion: "Generate a token at https://supabase.com/dashboard/account/tokens", + source, }); } }); diff --git a/apps/cli/src/next/commands/branches/errors.ts b/apps/cli/src/next/commands/branches/errors.ts index 0c0dada1aa..f9ca32c23f 100644 --- a/apps/cli/src/next/commands/branches/errors.ts +++ b/apps/cli/src/next/commands/branches/errors.ts @@ -1,16 +1,33 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; export class BranchNotFoundError extends Data.TaggedError("BranchNotFoundError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class NoBranchNameError extends Data.TaggedError("NoBranchNameError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class BranchAlreadyExistsError extends Data.TaggedError("BranchAlreadyExistsError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts index b13a63f0fc..8b19629c01 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-edge-runtime-config.ts @@ -7,6 +7,11 @@ import { import type { EdgeRuntimeConfig } from "@supabase/stack/effect"; import { Data, Effect, Redacted } from "effect"; import { ProjectHome } from "../../../config/project-home.service.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; type ResolvedSecretValue = string | Redacted.Redacted; type EdgeRuntimePolicy = "oneshot" | "per_worker"; @@ -27,6 +32,10 @@ export class FunctionsDevEdgeRuntimeDisabledError extends Data.TaggedError( override get message() { return `${this.detail}\n Suggestion: ${this.suggestion}`; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } export interface ResolvedFunctionsDevEdgeRuntimeConfig { diff --git a/apps/cli/src/next/commands/functions/new/new.errors.ts b/apps/cli/src/next/commands/functions/new/new.errors.ts index 4da86bf457..060f83bd15 100644 --- a/apps/cli/src/next/commands/functions/new/new.errors.ts +++ b/apps/cli/src/next/commands/functions/new/new.errors.ts @@ -1,18 +1,35 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../../shared/telemetry/error-actionability.ts"; export class InvalidFunctionSlugError extends Data.TaggedError("InvalidFunctionSlugError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class MissingFunctionSlugError extends Data.TaggedError("MissingFunctionSlugError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class FunctionEntrypointExistsError extends Data.TaggedError( "FunctionEntrypointExistsError", )<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/next/commands/link/link.errors.ts b/apps/cli/src/next/commands/link/link.errors.ts index 06e8ff24d2..21d3f9c3ff 100644 --- a/apps/cli/src/next/commands/link/link.errors.ts +++ b/apps/cli/src/next/commands/link/link.errors.ts @@ -1,11 +1,24 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; export class ProjectRefRequiredError extends Data.TaggedError("ProjectRefRequiredError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.missingProjectRef; + } +} export class NoAccessibleProjectsError extends Data.TaggedError("NoAccessibleProjectsError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.accountAccess; + } +} diff --git a/apps/cli/src/next/commands/login/login.errors.ts b/apps/cli/src/next/commands/login/login.errors.ts index a1bd067583..3c58f5708c 100644 --- a/apps/cli/src/next/commands/login/login.errors.ts +++ b/apps/cli/src/next/commands/login/login.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; function LoginError(tag: Tag) { return class extends Data.TaggedError(tag)<{ @@ -11,5 +16,33 @@ function LoginError(tag: Tag) { }; } -export class NoTtyError extends LoginError("NoTtyError") {} -export class LoginFailedError extends LoginError("LoginFailedError") {} +export class NoTtyError extends LoginError("NoTtyError") { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authToken; + } +} +/** + * All browser-login verification retries exhausted. Carries the LAST poll + * failure's discriminant so classification distinguishes "the user never + * completed the browser flow" (a pending 4xx, or no signal) from a genuine + * platform problem (5xx / transport). + */ +export class LoginFailedError extends Data.TaggedError("LoginFailedError")<{ + readonly detail: string; + readonly suggestion: string; + readonly statusCode?: number; + readonly network?: boolean; +}> { + override get message() { + return `${this.detail}\n Suggestion: ${this.suggestion}`; + } + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.network === true) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + if (this.statusCode !== undefined && this.statusCode >= 500) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_status" }; + } + return actionability.authLogin; + } +} diff --git a/apps/cli/src/next/commands/login/login.handler.ts b/apps/cli/src/next/commands/login/login.handler.ts index a0006a25cd..c6deff6bb1 100644 --- a/apps/cli/src/next/commands/login/login.handler.ts +++ b/apps/cli/src/next/commands/login/login.handler.ts @@ -18,14 +18,23 @@ import { } from "../../../shared/telemetry/identity.ts"; import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; import { withAnalyticsContext } from "../../../shared/telemetry/analytics-context.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; import type { NonInteractiveError } from "../../../shared/output/errors.ts"; import { LoginFailedError, NoTtyError } from "./login.errors.ts"; import type { LoginFlags } from "./login.command.ts"; -class LoginVerificationError extends Data.TaggedError("LoginVerificationError")<{ +export class LoginVerificationError extends Data.TaggedError("LoginVerificationError")<{ cause: ApiError; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} const MAX_LOGIN_VERIFICATION_RETRIES = 2; @@ -89,34 +98,48 @@ const captureLoginCompleted = Effect.fnUntraced(function* ( ); }); -const saveDirectToken = Effect.fnUntraced(function* (token: Redacted.Redacted) { +const saveDirectToken = Effect.fnUntraced(function* ( + token: Redacted.Redacted, + source: "env" | "flag" | "stdin", +) { const credentials = yield* Credentials; const output = yield* Output; - yield* validateToken(revealToken(token)); + yield* validateToken(revealToken(token), source); yield* credentials.saveAccessToken(token); const distinctId = yield* resolveAuthenticatedDistinctId(token); yield* output.success("Logged in successfully.", { command: "login" }); yield* captureLoginCompleted({ login_method: "token" }, distinctId); }); +interface ResolvedToken { + readonly token: Redacted.Redacted; + readonly source: "env" | "flag" | "stdin"; +} + // Token resolution priority: --token flag > SUPABASE_ACCESS_TOKEN env > piped stdin > interactive browser flow const resolveToken = Effect.fnUntraced(function* (tokenFlag: Option.Option) { - if (Option.isSome(tokenFlag)) return Option.some(Redacted.make(tokenFlag.value)); + if (Option.isSome(tokenFlag)) { + return Option.some({ token: Redacted.make(tokenFlag.value), source: "flag" }); + } const cliConfig = yield* CliConfig; - if (Option.isSome(cliConfig.accessToken)) return cliConfig.accessToken; + if (Option.isSome(cliConfig.accessToken)) { + return Option.some({ token: cliConfig.accessToken.value, source: "env" }); + } const stdin = yield* Stdin; if (!stdin.isTTY) { const piped = yield* stdin.readPipedText; - if (Option.isSome(piped)) return Option.some(Redacted.make(piped.value)); + if (Option.isSome(piped)) { + return Option.some({ token: Redacted.make(piped.value), source: "stdin" }); + } return yield* new NoTtyError({ detail: "Cannot prompt for token in non-interactive mode", suggestion: "Pass --token or set SUPABASE_ACCESS_TOKEN", }); } - return Option.none(); + return Option.none(); }); // --------------------------------------------------------------------------- @@ -187,14 +210,22 @@ const browserOAuthFlow = Effect.fnUntraced(function* (flags: LoginFlags) { remainingRetries: number, ): Effect.Effect => verifyCode.pipe( - Effect.catchTag("LoginVerificationError", () => + Effect.catchTag("LoginVerificationError", (err) => Effect.gen(function* () { yield* output.error("Verification failed"); if (remainingRetries <= 0) { + // Thread the last poll failure's discriminant: a received status + // classifies by code (5xx → platform outage), a status-less/decode + // failure means transport (network) unless it carried a decoded + // body, and a pending 4xx / no signal stays "run supabase login". + const { statusCode, decode } = err.cause; + const network = statusCode === undefined && decode !== true; return yield* Effect.fail( new LoginFailedError({ detail: "Login failed after maximum retries", suggestion: "Try running `supabase login` again", + statusCode, + network, }), ); } @@ -239,7 +270,7 @@ export const login = Effect.fnUntraced(function* (flags: LoginFlags) { const resolved = yield* resolveToken(flags.token); if (Option.isSome(resolved)) { - return yield* saveDirectToken(resolved.value); + return yield* saveDirectToken(resolved.value.token, resolved.value.source); } return yield* browserOAuthFlow(flags); }); diff --git a/apps/cli/src/next/commands/logs/logs.errors.ts b/apps/cli/src/next/commands/logs/logs.errors.ts index 2fc9866f60..f2e4ea812c 100644 --- a/apps/cli/src/next/commands/logs/logs.errors.ts +++ b/apps/cli/src/next/commands/logs/logs.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; export class UnsupportedLogsOutputFormatError extends Data.TaggedError( "UnsupportedLogsOutputFormatError", @@ -9,4 +14,8 @@ export class UnsupportedLogsOutputFormatError extends Data.TaggedError( override get message() { return `${this.detail}\n Suggestion: ${this.suggestion}`; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } diff --git a/apps/cli/src/next/commands/platform/platform.errors.ts b/apps/cli/src/next/commands/platform/platform.errors.ts index cd049e7987..d0372c4604 100644 --- a/apps/cli/src/next/commands/platform/platform.errors.ts +++ b/apps/cli/src/next/commands/platform/platform.errors.ts @@ -1,24 +1,46 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../../shared/telemetry/error-actionability.ts"; + export class PlatformInputError extends Data.TaggedError("PlatformInputError")<{ readonly message: string; readonly detail?: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class PlatformMetadataError extends Data.TaggedError("PlatformMetadataError")<{ readonly message: string; readonly detail?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.impossibleState; + } +} export class PlatformRouteNotFoundError extends Data.TaggedError("PlatformRouteNotFoundError")<{ readonly message: string; readonly detail?: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class PlatformMethodSelectionError extends Data.TaggedError("PlatformMethodSelectionError")<{ readonly message: string; readonly detail?: string; readonly suggestion?: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} diff --git a/apps/cli/src/next/config/project-link-remote.layer.ts b/apps/cli/src/next/config/project-link-remote.layer.ts index cd444e93cd..f697973fd5 100644 --- a/apps/cli/src/next/config/project-link-remote.layer.ts +++ b/apps/cli/src/next/config/project-link-remote.layer.ts @@ -1,5 +1,10 @@ import { Data, Duration, Effect, Exit, Layer } from "effect"; import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { PlatformApi } from "../auth/platform-api.service.ts"; import { CliConfig } from "./cli-config.service.ts"; import { @@ -10,13 +15,21 @@ import { } from "./project-link-remote.service.ts"; import type { LinkedServiceVersions } from "./project-link-state.service.ts"; -class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ +export class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ readonly service: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} -class NoProjectApiKeyError extends Data.TaggedError("NoProjectApiKeyError")<{ +export class NoProjectApiKeyError extends Data.TaggedError("NoProjectApiKeyError")<{ readonly projectRef: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} type ProjectApiKey = { readonly name: string; diff --git a/apps/cli/src/next/config/project-link-state.service.ts b/apps/cli/src/next/config/project-link-state.service.ts index 5e5ea316a5..04cb75fa81 100644 --- a/apps/cli/src/next/config/project-link-state.service.ts +++ b/apps/cli/src/next/config/project-link-state.service.ts @@ -1,5 +1,10 @@ import type { Effect, Option } from "effect"; import { Data, Schema, Context } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; const LinkedServiceVersionsSchema = Schema.Struct({ postgres: Schema.optionalKey(Schema.String), @@ -37,12 +42,20 @@ export type ProjectLinkStateValue = Schema.Schema.Type {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.relinkProject; + } +} export class ProjectNotLinkedError extends Data.TaggedError("ProjectNotLinkedError")<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.projectNotLinked; + } +} interface ProjectLinkStateShape { readonly load: Effect.Effect, InvalidProjectLinkStateError>; diff --git a/apps/cli/src/next/config/project-local-service-versions.service.ts b/apps/cli/src/next/config/project-local-service-versions.service.ts index 77168189df..59b7dd2083 100644 --- a/apps/cli/src/next/config/project-local-service-versions.service.ts +++ b/apps/cli/src/next/config/project-local-service-versions.service.ts @@ -1,5 +1,10 @@ import type { Effect, Option } from "effect"; import { Data, Schema, Context } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; const LocalServiceVersionsSchema = Schema.Struct({ postgres: Schema.optionalKey(Schema.String), @@ -28,7 +33,11 @@ export class InvalidLocalServiceVersionsStateError extends Data.TaggedError( )<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} interface ProjectLocalServiceVersionsShape { readonly load: Effect.Effect< diff --git a/apps/cli/src/next/config/service-version-resolution.ts b/apps/cli/src/next/config/service-version-resolution.ts index 62f9aedb82..dccd3321c3 100644 --- a/apps/cli/src/next/config/service-version-resolution.ts +++ b/apps/cli/src/next/config/service-version-resolution.ts @@ -6,17 +6,26 @@ import { SERVICE_NAMES, } from "@supabase/stack/effect"; import { Data, Effect, Option } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../../shared/telemetry/error-actionability.ts"; import { ProjectLocalServiceVersions } from "./project-local-service-versions.service.ts"; import { ProjectLinkState } from "./project-link-state.service.ts"; export type ResolvedServiceVersionContext = StackVersionPlan; -class InvalidServiceVersionOverrideError extends Data.TaggedError( +export class InvalidServiceVersionOverrideError extends Data.TaggedError( "InvalidServiceVersionOverrideError", )<{ readonly detail: string; readonly suggestion: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} function isServiceName(value: string): value is ServiceName { return (SERVICE_NAMES as ReadonlyArray).includes(value); diff --git a/apps/cli/src/shared/functions/delete.errors.ts b/apps/cli/src/shared/functions/delete.errors.ts index ba0ee0a761..b7357ee9de 100644 --- a/apps/cli/src/shared/functions/delete.errors.ts +++ b/apps/cli/src/shared/functions/delete.errors.ts @@ -1,19 +1,42 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../telemetry/error-actionability.ts"; export class InvalidFunctionSlugError extends Data.TaggedError("InvalidFunctionSlugError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class FunctionNotFoundError extends Data.TaggedError("FunctionNotFoundError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class DeleteFunctionNetworkError extends Data.TaggedError("DeleteFunctionNetworkError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} export class DeleteFunctionUnexpectedStatusError extends Data.TaggedError( "DeleteFunctionUnexpectedStatusError", )<{ + readonly status: number; readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} diff --git a/apps/cli/src/shared/functions/delete.ts b/apps/cli/src/shared/functions/delete.ts index 4e766c2952..0c8758461d 100644 --- a/apps/cli/src/shared/functions/delete.ts +++ b/apps/cli/src/shared/functions/delete.ts @@ -72,6 +72,7 @@ export function deleteFunction( const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); return yield* Effect.fail( new DeleteFunctionUnexpectedStatusError({ + status: response.status, message: `unexpected delete function status ${response.status}: ${body}`, }), ); diff --git a/apps/cli/src/shared/functions/deploy.errors.ts b/apps/cli/src/shared/functions/deploy.errors.ts index b1ac44b74e..4a4cbc13b3 100644 --- a/apps/cli/src/shared/functions/deploy.errors.ts +++ b/apps/cli/src/shared/functions/deploy.errors.ts @@ -1,21 +1,42 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; export class ConflictingFunctionDeployFlagsError extends Data.TaggedError( "ConflictingFunctionDeployFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class InvalidFunctionDeploySlugError extends Data.TaggedError( "InvalidFunctionDeploySlugError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class NoFunctionsToDeployError extends Data.TaggedError("NoFunctionsToDeployError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class FunctionDeployCancelledError extends Data.TaggedError("FunctionDeployCancelledError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.cancelled; + } +} diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 51f0d9a9a8..ab9f9e7e65 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -31,6 +31,7 @@ import { InvalidFunctionDeploySlugError, NoFunctionsToDeployError, } from "./deploy.errors.ts"; +import { FunctionsApiStatusError, FunctionsApiTransportError } from "./functions-api.errors.ts"; const COMPRESSED_ESZIP_MAGIC = "EZBR"; const DENO1_EDGE_RUNTIME_VERSION = "1.68.4"; @@ -155,17 +156,17 @@ function decodeFunctionListResponse(value: unknown): ReadonlyArray> { @@ -1427,7 +1428,7 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( }); const listRemoteFunctions = Effect.fnUntraced(function* (api: ApiClient, projectRef: string) { - let lastError: Error | undefined; + let lastError: Error | FunctionsApiStatusError | undefined; for (let attempt = 0; attempt <= 3; attempt += 1) { const result = yield* api .executeRaw(operationDefinitions.v1ListAllFunctions, { ref: projectRef }) @@ -1452,7 +1453,10 @@ const listRemoteFunctions = Effect.fnUntraced(function* (api: ApiClient, project ), }); } - lastError = new Error(`unexpected list functions status ${result.response.status}: ${body}`); + lastError = new FunctionsApiStatusError({ + status: result.response.status, + message: `unexpected list functions status ${result.response.status}: ${body}`, + }); if (result.response.status < 500 && result.response.status !== 429) { return yield* Effect.fail(lastError); } @@ -1570,7 +1574,10 @@ const uploadFunctionSource = Effect.fnUntraced(function* ( const body = yield* response.body; if (response.status !== 201) { return yield* Effect.fail( - new Error(`unexpected deploy status ${response.status}: ${JSON.stringify(body)}`), + new FunctionsApiStatusError({ + status: response.status, + message: `unexpected deploy status ${response.status}: ${JSON.stringify(body)}`, + }), ); } return yield* Effect.try({ @@ -1603,7 +1610,7 @@ const bulkUpdateRemoteFunctions = Effect.fnUntraced(function* ( projectRef: string, functions: ReadonlyArray, ) { - let lastError: Error | undefined; + let lastError: Error | FunctionsApiStatusError | undefined; for (let attempt = 0; attempt <= 3; attempt += 1) { const result = yield* rateLimitedRequest("bulk updating functions", () => api @@ -1636,7 +1643,10 @@ const bulkUpdateRemoteFunctions = Effect.fnUntraced(function* ( if (result.response.status === 200) { return; } - lastError = new Error(`unexpected bulk update status ${result.response.status}: ${body}`); + lastError = new FunctionsApiStatusError({ + status: result.response.status, + message: `unexpected bulk update status ${result.response.status}: ${body}`, + }); if (result.response.status < 500) { return yield* Effect.fail(lastError); } @@ -1658,7 +1668,7 @@ const upsertBundledFunction = Effect.fnUntraced(function* ( exists: boolean, ) { let shouldUpdate = exists; - let lastError: Error | undefined; + let lastError: Error | FunctionsApiStatusError | undefined; for (let attempt = 0; attempt <= 3; attempt += 1) { const action = shouldUpdate ? "update" : "create"; @@ -1708,9 +1718,10 @@ const upsertBundledFunction = Effect.fnUntraced(function* ( if (!shouldUpdate && body.includes("Duplicated function slug")) { shouldUpdate = true; } - lastError = new Error( - `unexpected ${action} function status ${response.value.status}: ${body}`, - ); + lastError = new FunctionsApiStatusError({ + status: response.value.status, + message: `unexpected ${action} function status ${response.value.status}: ${body}`, + }); } else { lastError = response.error; } @@ -1740,7 +1751,10 @@ const deleteRemoteFunction = Effect.fnUntraced(function* ( } const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); return yield* Effect.fail( - new Error(`unexpected delete function status ${response.status}: ${body}`), + new FunctionsApiStatusError({ + status: response.status, + message: `unexpected delete function status ${response.status}: ${body}`, + }), ); }); diff --git a/apps/cli/src/shared/functions/download.errors.ts b/apps/cli/src/shared/functions/download.errors.ts index b12aeca7af..dc6aaadda5 100644 --- a/apps/cli/src/shared/functions/download.errors.ts +++ b/apps/cli/src/shared/functions/download.errors.ts @@ -1,29 +1,54 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; export class InvalidFunctionSlugError extends Data.TaggedError("InvalidFunctionSlugError")<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class ConflictingFunctionDownloadFlagsError extends Data.TaggedError( "ConflictingFunctionDownloadFlagsError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class FunctionDownloadNotFoundError extends Data.TaggedError( "FunctionDownloadNotFoundError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} export class InvalidFunctionDownloadResponseError extends Data.TaggedError( "InvalidFunctionDownloadResponseError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.apiStatus; + } +} export class UnsafeFunctionDownloadPathError extends Data.TaggedError( "UnsafeFunctionDownloadPathError", )<{ readonly message: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 7b9882f6dd..e57fde578a 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -23,6 +23,7 @@ import { InvalidFunctionSlugError, UnsafeFunctionDownloadPathError, } from "./download.errors.ts"; +import { FunctionsApiStatusError, FunctionsApiTransportError } from "./functions-api.errors.ts"; const legacyEntrypointPath = "file:///src/index.ts"; @@ -149,17 +150,17 @@ function validateDownloadFlags( ); } -function mapTransportError(prefix: string, error: unknown): Error { +function mapTransportError(prefix: string, error: unknown): FunctionsApiTransportError { if (HttpClientError.isHttpClientError(error)) { const description = error.reason.description ?? error.reason._tag; - return new Error(`${prefix}: ${description}`); + return new FunctionsApiTransportError({ message: `${prefix}: ${description}` }); } if (error instanceof Error) { - return new Error(`${prefix}: ${error.message}`); + return new FunctionsApiTransportError({ message: `${prefix}: ${error.message}` }); } - return new Error(`${prefix}: ${String(error)}`); + return new FunctionsApiTransportError({ message: `${prefix}: ${String(error)}` }); } function hasEntrypointPath(metadata: DownloadMetadata | undefined): metadata is { @@ -589,7 +590,10 @@ const listRemoteFunctionSlugs = Effect.fnUntraced(function* (api: ApiClient, pro const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); if (response.status !== 200) { return yield* Effect.fail( - new Error(`unexpected list functions status ${response.status}: ${body}`), + new FunctionsApiStatusError({ + status: response.status, + message: `unexpected list functions status ${response.status}: ${body}`, + }), ); } @@ -635,7 +639,10 @@ const getRemoteFunction = Effect.fnUntraced(function* ( ); default: return yield* Effect.fail( - new Error(`Failed to download Function ${slug} on the Supabase project: ${body}`), + new FunctionsApiStatusError({ + status: response.status, + message: `Failed to download Function ${slug} on the Supabase project: ${body}`, + }), ); } @@ -675,7 +682,12 @@ const downloadBody = Effect.fnUntraced(function* ( } const body = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* Effect.fail(new Error(`Error status ${response.status}: ${body}`)); + return yield* Effect.fail( + new FunctionsApiStatusError({ + status: response.status, + message: `Error status ${response.status}: ${body}`, + }), + ); }); const downloadSingle = Effect.fnUntraced(function* ( diff --git a/apps/cli/src/shared/functions/functions-api.errors.ts b/apps/cli/src/shared/functions/functions-api.errors.ts new file mode 100644 index 0000000000..63280c85d3 --- /dev/null +++ b/apps/cli/src/shared/functions/functions-api.errors.ts @@ -0,0 +1,40 @@ +import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../telemetry/error-actionability.ts"; + +/** + * Shared error for a non-OK Management API response where an HTTP status + * code is available, used by both `deploy.ts` and `download.ts`. Keeping one + * class here (rather than duplicating it per file) lets both call sites + * classify identically via {@link statusCodeActionability} instead of + * falling back to a plain `Error` (which reports as `unknown` in the error + * actionability KPI). + */ +export class FunctionsApiStatusError extends Data.TaggedError("FunctionsApiStatusError")<{ + readonly status: number; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} + +/** + * Shared error for a Management API request that failed before a response + * was received (DNS, connection reset, timeout, ...), used by both + * `deploy.ts` and `download.ts`'s `mapTransportError`. Keeping one class here + * (rather than duplicating it per file) lets both call sites classify + * identically as a network failure instead of falling back to a plain + * `Error` (which reports as `unknown` in the error actionability KPI). + */ +export class FunctionsApiTransportError extends Data.TaggedError("FunctionsApiTransportError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } +} diff --git a/apps/cli/src/shared/init/project-init.errors.ts b/apps/cli/src/shared/init/project-init.errors.ts index edcd4e069c..6d685a76f3 100644 --- a/apps/cli/src/shared/init/project-init.errors.ts +++ b/apps/cli/src/shared/init/project-init.errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; export class InitAlreadyExistsError extends Data.TaggedError("InitAlreadyExistsError")<{ readonly detail: string; @@ -7,6 +12,10 @@ export class InitAlreadyExistsError extends Data.TaggedError("InitAlreadyExistsE override get message() { return "A Supabase project is already initialized in this directory."; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } export class InitExperimentalRequiredError extends Data.TaggedError( @@ -18,6 +27,10 @@ export class InitExperimentalRequiredError extends Data.TaggedError( override get message() { return "The --use-orioledb flag requires --experimental."; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } export class InitParseSettingsError extends Data.TaggedError("InitParseSettingsError")<{ @@ -27,4 +40,8 @@ export class InitParseSettingsError extends Data.TaggedError("InitParseSettingsE override get message() { return "Failed to parse existing IDE settings file."; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } } diff --git a/apps/cli/src/shared/output/errors.ts b/apps/cli/src/shared/output/errors.ts index 02e93c8143..588108e0a6 100644 --- a/apps/cli/src/shared/output/errors.ts +++ b/apps/cli/src/shared/output/errors.ts @@ -1,4 +1,9 @@ import { Data } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; export class NonInteractiveError extends Data.TaggedError("NonInteractiveError")<{ readonly detail: string; @@ -7,4 +12,8 @@ export class NonInteractiveError extends Data.TaggedError("NonInteractiveError") override get message() { return `${this.detail}\n Suggestion: ${this.suggestion}`; } + + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } } diff --git a/apps/cli/src/shared/runtime/file-watcher.service.ts b/apps/cli/src/shared/runtime/file-watcher.service.ts index b7dfb6c05a..70f0c5f047 100644 --- a/apps/cli/src/shared/runtime/file-watcher.service.ts +++ b/apps/cli/src/shared/runtime/file-watcher.service.ts @@ -1,6 +1,12 @@ import type { Stream } from "effect"; import { Data, Context } from "effect"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; + type FileWatchEventType = "create" | "update" | "delete"; export interface FileWatchEvent { @@ -15,7 +21,11 @@ export interface FileWatchOptions { export class FileWatcherError extends Data.TaggedError("FileWatcherError")<{ readonly path: string; readonly cause: unknown; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.permission; + } +} interface FileWatcherShape { readonly watch: ( diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 05840cb893..38273b72f5 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -4,6 +4,11 @@ import { Data, Duration, Effect, Exit, Redacted } from "effect"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; import { renderGlamourTable } from "../../legacy/output/legacy-glamour-table.ts"; +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, +} from "../telemetry/error-actionability.ts"; import { dockerfileServiceImages, parseDockerfileServiceImages, @@ -197,9 +202,13 @@ export interface ServiceFetchConfig { readonly tenantBaseUrlOverride?: string; } -class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ +export class ServiceVersionNotFoundError extends Data.TaggedError("ServiceVersionNotFoundError")<{ readonly service: string; -}> {} +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} function fieldValue(value: unknown, key: string): unknown { if (typeof value !== "object" || value === null) { diff --git a/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts new file mode 100644 index 0000000000..2f66b2103b --- /dev/null +++ b/apps/cli/src/shared/telemetry/error-actionability-coverage.unit.test.ts @@ -0,0 +1,172 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +// Vitest (via Vite) provides `import.meta.glob` at runtime; the workspace +// tsconfig does not load `vite/client`, so declare the one member we use. +declare global { + interface ImportMeta { + readonly glob: (patterns: ReadonlyArray) => Record Promise>; + } +} +import { + CliErrorCategory, + CliErrorKind, + CliSuggestionType, + ErrorActionabilityId, + isClassifiedExternalErrorTag, +} from "./error-actionability.ts"; + +/** + * Drift guard for the error actionability taxonomy: every error class defined + * in `apps/cli/src` must declare its own classification under + * {@link ErrorActionabilityId}, and every error tag defined in the workspace + * packages that can surface through CLI commands must have an external + * adapter. A new error type failing here is the feature — `unknown` in + * production telemetry must mean "genuinely unforeseen failure", never "we + * forgot to classify". + */ + +// Matches every way an error class is defined in this workspace: direct +// `Data.TaggedError("Tag")`, any local `*Error(...)` factory whose heritage +// call carries the tag literal (`CliError("Tag")`, `LoginError("Tag")`, ...), +// and plain `extends Error` classes (identified by class name). Error +// factories must therefore be named `Error` to stay guarded — +// which also keeps `Data.TaggedClass` event types out of the scan. +const ERROR_DEFINITION_PATTERN = + /TaggedError\(\s*"([A-Za-z0-9_]+)"|class\s+[A-Za-z0-9_]+\s+extends\s+[A-Za-z0-9_.]*Error\(\s*"([A-Za-z0-9_]+)"|class\s+([A-Za-z0-9_]+)\s+extends\s+Error\b/gs; + +function scanErrorTags(root: string): Map> { + const tagsByFile = new Map>(); + const walk = (dir: string) => { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) { + walk(path); + continue; + } + if (!path.endsWith(".ts") || path.endsWith(".test.ts")) continue; + const tags = [...readFileSync(path, "utf8").matchAll(ERROR_DEFINITION_PATTERN)].map( + (match) => match[1] ?? match[2] ?? match[3] ?? "", + ); + if (tags.length > 0) tagsByFile.set(path, tags); + } + }; + walk(root); + return tagsByFile; +} + +const kindValues = new Set(Object.values(CliErrorKind)); +const categoryValues = new Set(Object.values(CliErrorCategory)); +const suggestionValues = new Set(Object.values(CliSuggestionType)); + +interface DeclaredErrorClass { + readonly exportName: string; + readonly tag: string; + readonly prototype: object; +} + +function collectErrorClasses(module: Record): Array { + const classes: Array = []; + for (const [exportName, value] of Object.entries(module)) { + if (typeof value !== "function") continue; + const prototype: unknown = value.prototype; + if (typeof prototype !== "object" || prototype === null) continue; + if (!(prototype instanceof Error)) continue; + // effect V4 assigns `_tag` per instance, so probe with an empty props bag. + // Plain `extends Error` classes have no `_tag`; identify them by class name. + let tag: unknown; + try { + tag = Reflect.get(Reflect.construct(value, [{}]), "_tag"); + } catch { + tag = undefined; + } + classes.push({ exportName, tag: typeof tag === "string" ? tag : exportName, prototype }); + } + return classes; +} + +const srcRoot = resolve(import.meta.dirname, "../.."); +const repoRoot = resolve(import.meta.dirname, "../../../../.."); + +const moduleLoaders = new Map( + Object.entries(import.meta.glob(["../../**/*.ts", "!**/*.test.ts"])).map(([key, loader]) => [ + resolve(import.meta.dirname, key), + loader, + ]), +); + +describe("apps/cli error classes declare their actionability", () => { + const tagsByFile = scanErrorTags(srcRoot); + + it("finds the error definition surface", () => { + expect(tagsByFile.size).toBeGreaterThan(50); + }); + + for (const [file, tags] of tagsByFile) { + const relativePath = file.slice(srcRoot.length + 1); + // Importing a command module can pull in a large transitive graph on first + // load; give these dynamic-import tests more headroom than the default 5s. + it(relativePath, { timeout: 30_000 }, async () => { + const loader = moduleLoaders.get(file); + expect(loader, `no module loader for ${relativePath}`).toBeDefined(); + const module = await loader?.(); + expect(typeof module).toBe("object"); + const classes = collectErrorClasses(Object(module)); + + const exportedTags = new Set(classes.map((cls) => cls.tag)); + for (const tag of tags) { + expect( + exportedTags.has(tag), + `error "${tag}" is defined in ${relativePath} but not exported — export it so its actionability declaration is verifiable`, + ).toBe(true); + } + + for (const { exportName, tag, prototype } of classes) { + expect( + ErrorActionabilityId in prototype, + `${exportName} ("${tag}") does not declare [ErrorActionabilityId] — add a getter returning its CliErrorActionabilityDeclaration`, + ).toBe(true); + + // Evaluate the getter against a field-less probe: instance-dependent + // declarations must degrade to a valid declaration when fields are + // absent, and static ones are checked directly. + const probe: object = Object.create(prototype); + const declaration: unknown = Reflect.get(probe, ErrorActionabilityId); + expect( + typeof declaration === "object" && declaration !== null, + `${exportName} ("${tag}") declaration is not an object`, + ).toBe(true); + const record: Record = Object(declaration); + expect(kindValues.has(String(record["error_kind"]))).toBe(true); + expect(categoryValues.has(String(record["error_category"]))).toBe(true); + expect(typeof record["has_suggestion"]).toBe("boolean"); + expect(suggestionValues.has(String(record["suggestion_type"]))).toBe(true); + } + }); + } +}); + +describe("workspace package error tags have external adapters", () => { + const packageRoots = [ + "packages/api/src", + "packages/stack/src", + "packages/config/src", + "packages/process-compose/src", + ]; + + for (const packageRoot of packageRoots) { + it(packageRoot, () => { + const tagsByFile = scanErrorTags(resolve(repoRoot, packageRoot)); + expect(tagsByFile.size).toBeGreaterThan(0); + for (const [file, tags] of tagsByFile) { + for (const tag of tags) { + expect( + isClassifiedExternalErrorTag(tag), + `"${tag}" (${file.slice(repoRoot.length + 1)}) has no external adapter in error-actionability.ts`, + ).toBe(true); + } + } + }); + } +}); diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts new file mode 100644 index 0000000000..3d07fe4eb3 --- /dev/null +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -0,0 +1,695 @@ +import { Cause, Option } from "effect"; + +/** + * CLI error actionability taxonomy for KPI reporting (CLI-1560). + * + * Classification is declared where each error is defined: every error class in + * `apps/cli/src` exposes a {@link CliErrorActionabilityDeclaration} under the + * {@link ErrorActionabilityId} symbol (enforced by + * `error-actionability-coverage.unit.test.ts`). Errors originating outside the + * CLI workspace (`@supabase/stack`, `@supabase/config`, + * `@supabase/process-compose`, `effect` cli/http) are classified by the + * structural adapters at the bottom of this module, which are themselves + * exhaustiveness-checked against those packages' sources. + * + * Everything emitted from here is sanitized by construction: kinds, categories, + * suggestion types, and fingerprints are closed enums or `tag:`-prefixed safe + * identifiers — never raw error text or user-specific data. + */ + +export const CliErrorKind = { + UserActionable: "user_actionable", + InternalBug: "internal_bug", + ExternalService: "external_service", + UserCancelled: "user_cancelled", + Unknown: "unknown", +} as const; + +export type CliErrorKind = (typeof CliErrorKind)[keyof typeof CliErrorKind]; + +export const CliErrorCategory = { + Auth: "auth", + MissingProjectRef: "missing_project_ref", + ProjectNotLinked: "project_not_linked", + DockerNotRunning: "docker_not_running", + InvalidConfig: "invalid_config", + DbConnection: "db_connection", + MigrationDrift: "migration_drift", + Permission: "permission", + PlanLimit: "plan_limit", + ProjectPaused: "project_paused", + InvalidInput: "invalid_input", + Network: "network", + ApiStatus: "api_status", + Cancelled: "cancelled", + Panic: "panic", + ImpossibleState: "impossible_state", + Unknown: "unknown", +} as const; + +export type CliErrorCategory = (typeof CliErrorCategory)[keyof typeof CliErrorCategory]; + +export const CliSuggestionType = { + Login: "login", + LinkProject: "link_project", + StartDocker: "start_docker", + ProvideFlags: "provide_flags", + SetEnvVar: "set_env_var", + RepairMigration: "repair_migration", + UpdateConfig: "update_config", + UpgradePlan: "upgrade_plan", + RerunDebug: "rerun_debug", + OpenDashboard: "open_dashboard", + None: "none", +} as const; + +export type CliSuggestionType = (typeof CliSuggestionType)[keyof typeof CliSuggestionType]; + +/** Q2 KPI metric definitions, kept next to the taxonomy they consume. */ +export const CliErrorActionabilityMetricDefinitions = { + strictRecovery: { + id: "same_command_success_same_session", + description: + "A user-actionable error is recovered when the same command later succeeds in the same PostHog session.", + }, + repeatError: { + id: "same_command_same_error_same_session_before_success", + description: + "A user-actionable error repeats when the same command fails again with the same error fingerprint in the same PostHog session before success.", + }, + internalUnknownBugRate: { + id: "failed_commands_internal_bug_or_unknown", + description: + "Internal/unknown bug failure rate is the share of failed commands classified as internal_bug or unknown.", + }, +} as const; + +/** + * Symbol under which CLI error classes declare their own actionability. + * + * Declare it as a getter so it lives on the prototype (visible to the coverage + * test without instantiating the class) and can branch on instance fields: + * + * ```ts + * class MyStatusError extends Data.TaggedError(tag)<{ status: number }> { + * get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + * return statusCodeActionability(this.status); + * } + * } + * ``` + */ +export const ErrorActionabilityId: unique symbol = Symbol.for( + "@supabase/cli/telemetry/ErrorActionability", +); + +export interface CliErrorActionabilityDeclaration { + readonly error_kind: CliErrorKind; + readonly error_category: CliErrorCategory; + /** + * Whether this failure class has a canonical remediation. This is the + * taxonomy-level claim, not a guarantee that the CLI rendered a + * `Suggestion:` line for a given instance — some errors convey the fix in + * the message itself. Instance-level rendered suggestions (a non-empty + * `suggestion` field on the error, the same field `normalize-error.ts` + * renders as the `Suggestion:` line) are already reconciled with this + * declaration at classify time in `toActionability`: a false declaration + * flips to true when the instance carries one, but a true declaration is + * never flipped to false. Capture-time reconciliation remains necessary + * only for errors that convey remediation in the message text itself, + * which classify-time has no way to see. + */ + readonly has_suggestion: boolean; + readonly suggestion_type: CliSuggestionType; + readonly suggested_command?: string; + /** + * Distinguishes branches of instance-dependent declarations in the repeat + * fingerprint, so e.g. a registry pull failure and a daemon-down failure of + * the same wrapper tag never count as repeats of one another. Must be a + * static safe identifier, never derived from error text. + */ + readonly fingerprint_suffix?: string; +} + +/** Sanitized classification emitted with failed `cli_command_executed` events. */ +export interface CliErrorActionability { + readonly error_kind: CliErrorKind; + readonly error_category: CliErrorCategory; + readonly error_fingerprint: string; + readonly has_suggestion: boolean; + readonly suggestion_type: CliSuggestionType; + readonly suggested_command?: string; +} + +/** Shared declarations for the recurring classification shapes. */ +export const actionability = { + authLogin: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.Auth, + has_suggestion: true, + suggestion_type: CliSuggestionType.Login, + suggested_command: "supabase login", + }, + authToken: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.Auth, + has_suggestion: true, + suggestion_type: CliSuggestionType.SetEnvVar, + }, + provideFlags: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidInput, + has_suggestion: true, + suggestion_type: CliSuggestionType.ProvideFlags, + }, + invalidInput: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidInput, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, + invalidConfig: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + }, + /** + * A database operation failed because of the user's own SQL, schema, or + * data — actionable, but the CLI has no generic remediation to suggest. + */ + dbFinding: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, + dbConnection: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.DbConnection, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + }, + migrationDrift: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.MigrationDrift, + has_suggestion: true, + suggestion_type: CliSuggestionType.RepairMigration, + }, + permission: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.Permission, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + }, + accountAccess: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.Permission, + has_suggestion: true, + suggestion_type: CliSuggestionType.Login, + suggested_command: "supabase login", + }, + planLimit: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.PlanLimit, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpgradePlan, + }, + projectNotLinked: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.ProjectNotLinked, + has_suggestion: true, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }, + missingProjectRef: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.MissingProjectRef, + has_suggestion: true, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }, + /** Local link state exists but is unusable — re-linking repairs it. */ + relinkProject: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: true, + suggestion_type: CliSuggestionType.LinkProject, + suggested_command: "supabase link", + }, + dockerNotRunning: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.DockerNotRunning, + has_suggestion: true, + suggestion_type: CliSuggestionType.StartDocker, + }, + startStack: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + suggested_command: "supabase start", + }, + stopStack: { + error_kind: CliErrorKind.UserActionable, + error_category: CliErrorCategory.InvalidConfig, + has_suggestion: true, + suggestion_type: CliSuggestionType.UpdateConfig, + suggested_command: "supabase stop", + }, + externalNetwork: { + error_kind: CliErrorKind.ExternalService, + error_category: CliErrorCategory.Network, + has_suggestion: true, + suggestion_type: CliSuggestionType.RerunDebug, + }, + apiStatus: { + error_kind: CliErrorKind.ExternalService, + error_category: CliErrorCategory.ApiStatus, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, + cancelled: { + error_kind: CliErrorKind.UserCancelled, + error_category: CliErrorCategory.Cancelled, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, + internalPanic: { + error_kind: CliErrorKind.InternalBug, + error_category: CliErrorCategory.Panic, + has_suggestion: true, + suggestion_type: CliSuggestionType.RerunDebug, + }, + impossibleState: { + error_kind: CliErrorKind.InternalBug, + error_category: CliErrorCategory.ImpossibleState, + has_suggestion: true, + suggestion_type: CliSuggestionType.RerunDebug, + }, + unknown: { + error_kind: CliErrorKind.Unknown, + error_category: CliErrorCategory.Unknown, + has_suggestion: false, + suggestion_type: CliSuggestionType.None, + }, +} as const satisfies Record; + +/** + * The declaration for a failure confirmed plan-gated by the entitlement + * check (`legacySuggestUpgrade`). Shared so every gated surface groups under + * the same fingerprint family. + */ +export const planLimitGatedActionability: CliErrorActionabilityDeclaration = { + ...actionability.planLimit, + fingerprint_suffix: "plan_limit", +}; + +/** + * Classification policy for errors that carry a Management API status code. + * `upgradeSuggested` is the typed result of the entitlement gate + * (`legacySuggestUpgrade`) threaded through the error constructor — never + * inferred from message text. + */ +export function statusCodeActionability( + status: number | undefined, + opts: { readonly upgradeSuggested?: boolean } = {}, +): CliErrorActionabilityDeclaration { + if (status === 401) { + return { ...actionability.authLogin, fingerprint_suffix: "auth" }; + } + if (opts.upgradeSuggested === true && status !== undefined && status >= 400 && status < 500) { + return planLimitGatedActionability; + } + if (status === 403) { + return { ...actionability.accountAccess, fingerprint_suffix: "forbidden" }; + } + if (status === undefined) { + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + } + return { ...actionability.apiStatus, fingerprint_suffix: "api_status" }; +} + +type ErrorRecord = Record; + +function isErrorRecord(value: unknown): value is ErrorRecord { + return typeof value === "object" && value !== null; +} + +function readString(value: ErrorRecord, key: string): string | undefined { + const field = value[key]; + return typeof field === "string" && field.trim().length > 0 ? field.trim() : undefined; +} + +function readNumber(value: ErrorRecord, key: string): number | undefined { + const field = value[key]; + return typeof field === "number" && Number.isFinite(field) ? field : undefined; +} + +const kindValues = new Set(Object.values(CliErrorKind)); +const categoryValues = new Set(Object.values(CliErrorCategory)); +const suggestionValues = new Set(Object.values(CliSuggestionType)); + +function isDeclaration(value: unknown): value is CliErrorActionabilityDeclaration { + if (!isErrorRecord(value)) return false; + const kind = value["error_kind"]; + const category = value["error_category"]; + const suggestion = value["suggestion_type"]; + return ( + typeof kind === "string" && + kindValues.has(kind) && + typeof category === "string" && + categoryValues.has(category) && + typeof value["has_suggestion"] === "boolean" && + typeof suggestion === "string" && + suggestionValues.has(suggestion) + ); +} + +function readDeclaration(error: unknown): CliErrorActionabilityDeclaration | undefined { + if (!isErrorRecord(error)) return undefined; + if (!(ErrorActionabilityId in error)) return undefined; + const declared = error[ErrorActionabilityId]; + return isDeclaration(declared) ? declared : undefined; +} + +function safeIdentifier(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + return /^[A-Za-z][A-Za-z0-9_]*$/.test(value) ? value : undefined; +} + +function readErrorTag(error: unknown): string | undefined { + if (!isErrorRecord(error)) return undefined; + return safeIdentifier(readString(error, "_tag")); +} + +function readErrorName(error: unknown): string | undefined { + if (error instanceof Error) return safeIdentifier(error.name); + if (!isErrorRecord(error)) return undefined; + return safeIdentifier(readString(error, "name")); +} + +function fingerprint(prefix: string, identifier: string | undefined, suffix?: string): string { + const base = identifier === undefined ? `${prefix}:unknown` : `${prefix}:${identifier}`; + return suffix === undefined ? base : `${base}:${suffix}`; +} + +/** + * Whether the classified error instance carries a non-empty `suggestion` + * field — the same field `shared/output/normalize-error.ts` renders as the + * `Suggestion:` line. Used to reconcile a declaration's `has_suggestion` + * with what the user actually saw, without ever downgrading a declared true. + */ +function hasInstanceSuggestion(error: unknown): boolean { + return isErrorRecord(error) && readString(error, "suggestion") !== undefined; +} + +function toActionability( + declaration: CliErrorActionabilityDeclaration, + fingerprintPrefix: string, + identifier: string | undefined, + error?: unknown, +): CliErrorActionability { + const { fingerprint_suffix, ...rest } = declaration; + return { + ...rest, + has_suggestion: rest.has_suggestion || hasInstanceSuggestion(error), + error_fingerprint: fingerprint(fingerprintPrefix, identifier, fingerprint_suffix), + }; +} + +/** + * Adapters for errors defined outside `apps/cli`, keyed by `_tag`. Kept + * exhaustive against those packages' sources by + * `error-actionability-coverage.unit.test.ts`. Everything defined inside + * `apps/cli` must declare {@link ErrorActionabilityId} instead of being + * added here. + */ +const externalActionabilityByTag: Record< + string, + (error: ErrorRecord) => CliErrorActionabilityDeclaration +> = { + // effect/unstable/cli parser failures (ShowHelp and UserError recurse in + // classifyCliErrorActionability instead of mapping here) + MissingOption: () => actionability.invalidInput, + MissingArgument: () => actionability.invalidInput, + DuplicateOption: () => actionability.invalidInput, + InvalidValue: () => actionability.invalidInput, + UnknownSubcommand: () => actionability.invalidInput, + UnrecognizedOption: () => actionability.invalidInput, + + // effect PlatformError — OS/filesystem operations. `reason` is + // `BadArgument | SystemError`; BadArgument means the CLI itself passed a + // rejected argument (internal bug), SystemError reasons are local + // environment problems the user resolves. + PlatformError: (error) => { + const reason = error["reason"]; + const reasonTag = isErrorRecord(reason) + ? safeIdentifier(readString(reason, "_tag")) + : undefined; + if (reasonTag === "BadArgument") { + return { ...actionability.impossibleState, fingerprint_suffix: "bad_argument" }; + } + return { + ...actionability.permission, + ...(reasonTag !== undefined ? { fingerprint_suffix: reasonTag } : {}), + }; + }, + BadArgument: () => ({ ...actionability.impossibleState, fingerprint_suffix: "bad_argument" }), + + // @supabase/config + ProjectConfigParseError: () => actionability.invalidConfig, + ProjectEnvParseError: () => actionability.invalidConfig, + MissingProjectConfigValueError: () => actionability.invalidConfig, + DuplicateRemoteProjectIdError: () => actionability.invalidConfig, + InvalidRemoteProjectIdError: () => actionability.invalidConfig, + + // @supabase/api — client construction failed before any request (missing + // access token / bad configuration); remediation is the token env var. + SupabaseApiConfigError: () => actionability.authToken, + + // effect/unstable/http — generated Management API client transport/decoding + HttpClientError: (error) => { + const reason = error["reason"]; + const reasonTag = isErrorRecord(reason) ? readString(reason, "_tag") : undefined; + const response = error["response"]; + const status = isErrorRecord(response) ? readNumber(response, "status") : undefined; + if (status === 401) return { ...actionability.authLogin, fingerprint_suffix: "auth" }; + if (status === 403) return { ...actionability.accountAccess, fingerprint_suffix: "forbidden" }; + if (reasonTag === "StatusCodeError" || isErrorRecord(response)) { + return { ...actionability.apiStatus, fingerprint_suffix: "api_status" }; + } + return { ...actionability.externalNetwork, fingerprint_suffix: "network" }; + }, + HttpBodyError: () => ({ ...actionability.apiStatus, fingerprint_suffix: "api_response" }), + SchemaError: () => ({ ...actionability.apiStatus, fingerprint_suffix: "api_response" }), + + // @supabase/stack — StackError is a plain Error subclass matched by `name` + // in classifyCliErrorActionability, with a structured `code` field. + StackError: (error) => + readString(error, "code") === "PORT_ALLOCATION" + ? { ...actionability.invalidConfig, fingerprint_suffix: "port_allocation" } + : actionability.unknown, + BinaryNotFoundError: () => actionability.invalidConfig, + DownloadError: () => actionability.externalNetwork, + ChecksumMismatchError: () => ({ + ...actionability.externalNetwork, + fingerprint_suffix: "asset_checksum", + }), + DockerPullError: (error) => + error["daemonDown"] === true + ? { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" } + : { ...actionability.externalNetwork, fingerprint_suffix: "registry_pull" }, + StackBuildError: (error) => { + const reason = readString(error, "reason"); + if (reason === "invalid_config") { + return { ...actionability.invalidConfig, fingerprint_suffix: "invalid_config" }; + } + if (reason === "asset_preparation") { + return { ...actionability.externalNetwork, fingerprint_suffix: "asset_preparation" }; + } + return { ...actionability.impossibleState, fingerprint_suffix: "internal_build" }; + }, + PortConflictError: () => ({ + ...actionability.invalidConfig, + fingerprint_suffix: "port_conflict", + }), + PortAllocationError: () => ({ + ...actionability.invalidConfig, + fingerprint_suffix: "port_allocation", + }), + StateNotFoundError: () => actionability.startStack, + StackMetadataNotFoundError: () => actionability.startStack, + InvalidStackStateError: () => actionability.invalidConfig, + InvalidStackMetadataError: () => actionability.invalidConfig, + UnsupportedStackMetadataVersionError: () => actionability.invalidConfig, + NoRunningStackError: () => actionability.startStack, + StackAlreadyRunningError: () => actionability.stopStack, + DaemonStartError: () => actionability.startStack, + DaemonStillRunningError: () => actionability.stopStack, + // A `/start` RPC failure means the daemon never came up; every other path + // (status/logs/stop/dispose) means a running-but-broken daemon. + UnixHttpClientError: (error) => { + const path = readString(error, "path"); + if (path !== undefined && path.startsWith("/start")) { + return { ...actionability.startStack, fingerprint_suffix: "daemon_start" }; + } + return actionability.stopStack; + }, + + // @supabase/process-compose — the CLI generates the process graph, so graph + // invariants are internal bugs; runtime service failures are stack-state + // problems the user resolves by restarting the stack. + CyclicDependencyError: () => actionability.impossibleState, + MissingDependencyError: () => actionability.impossibleState, + ServiceNotFoundError: () => actionability.impossibleState, + SpawnError: () => actionability.startStack, + ShutdownTimeoutError: () => actionability.stopStack, + ServiceReadyError: () => actionability.startStack, +}; + +/** + * Whether a tag defined outside `apps/cli` has an external adapter. Used by + * the coverage test to keep {@link externalActionabilityByTag} exhaustive + * against the workspace packages. + */ +export function isClassifiedExternalErrorTag(tag: string): boolean { + return Object.hasOwn(externalActionabilityByTag, tag); +} + +/** + * A wrapper's preserved `cause`, but only when classifying it cannot degrade + * the result: the cause must carry its own declaration or a known external + * adapter tag, otherwise the wrapper's own classification is more truthful. + */ +function classifiableCause(error: ErrorRecord): ErrorRecord | undefined { + const cause = error["cause"]; + if (!isErrorRecord(cause)) return undefined; + if (readDeclaration(cause) !== undefined) return cause; + const causeTag = readErrorTag(cause); + if (causeTag !== undefined && Object.hasOwn(externalActionabilityByTag, causeTag)) return cause; + return undefined; +} + +function classifyShowHelp(error: ErrorRecord, depth: number): CliErrorActionability | undefined { + const errors = error["errors"]; + if (!Array.isArray(errors)) return undefined; + if (errors.length === 1) return classifyAtDepth(errors[0], depth + 1); + return toActionability(actionability.invalidInput, "tag", "ShowHelp", error); +} + +function isNativeJsExceptionName(name: string | undefined): boolean { + return ( + name === "TypeError" || + name === "ReferenceError" || + name === "RangeError" || + name === "SyntaxError" || + name === "EvalError" || + name === "URIError" || + name === "AggregateError" + ); +} + +/** + * Hard cap on cause-chain recursion (ShowHelp, UserError, and stack wrapper + * causes). Real chains are 1-2 deep; the cap exists so a cyclic or + * adversarial cause chain can never stack-overflow the failure-telemetry + * path itself. + */ +const MAX_CAUSE_DEPTH = 8; + +export function classifyCliErrorActionability(error: unknown): CliErrorActionability { + return classifyAtDepth(error, 0); +} + +function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { + if (depth >= MAX_CAUSE_DEPTH) { + return toActionability(actionability.unknown, "error", "CauseChainLimit", error); + } + const tag = readErrorTag(error); + + const declared = readDeclaration(error); + if (declared !== undefined) { + return toActionability(declared, "tag", tag ?? readErrorName(error), error); + } + + if (tag === "ShowHelp" && isErrorRecord(error)) { + const classified = classifyShowHelp(error, depth); + if (classified !== undefined) return classified; + } + + // effect cli wraps handler failures in UserError({ cause }) — classify the + // actual failure instead of the wrapper. + if (tag === "UserError" && isErrorRecord(error) && error["cause"] !== undefined) { + return classifyAtDepth(error["cause"], depth + 1); + } + + // @supabase/stack wrapper errors preserve the underlying tagged failure in + // `cause`; classify it when it is more specific than the wrapper (e.g. a + // daemon-down DockerPullError inside an asset-preparation StackBuildError, + // or a user's ProjectConfigParseError inside a reason-less StackBuildError). + // Explicit `invalid_config` StackBuildErrors are deliberate user-facing + // config verdicts and are never overridden by their cause. + if ( + isErrorRecord(error) && + tag === "StackBuildError" && + readString(error, "reason") !== "invalid_config" + ) { + const cause = classifiableCause(error); + if (cause !== undefined) { + return classifyAtDepth(cause, depth + 1); + } + } + + // DownloadError recurses ONLY into local filesystem causes (PlatformError: + // unwritable cache, extraction failure). HTTP causes stay on the wrapper — + // the HttpClientError adapter's 401/403 → auth/permission policy is + // Management-API-specific and must not apply to GitHub/CDN asset downloads. + if (isErrorRecord(error) && tag === "DownloadError") { + const cause = error["cause"]; + if (isErrorRecord(cause) && readErrorTag(cause) === "PlatformError") { + return classifyAtDepth(cause, depth + 1); + } + } + + if (tag !== undefined && isErrorRecord(error)) { + // Own-property lookup: a sanitized tag like "constructor" must not pick + // up Object.prototype members as adapters. + if (Object.hasOwn(externalActionabilityByTag, tag)) { + const external = externalActionabilityByTag[tag]; + if (external !== undefined) { + return toActionability(external(error), "tag", tag, error); + } + } + return toActionability(actionability.unknown, "tag", tag, error); + } + + if (isErrorRecord(error) && readErrorName(error) === "StackError") { + // The public Stack promise API wraps tagged failures via `toStackError`, + // preserving the original in `cause` — classify that instead of the + // wrapper whenever it is itself classifiable. + const cause = classifiableCause(error); + if (cause !== undefined) { + return classifyAtDepth(cause, depth + 1); + } + const classify = externalActionabilityByTag["StackError"]; + if (classify !== undefined) { + return toActionability(classify(error), "error", "StackError", error); + } + } + + if (typeof error === "string") { + return toActionability(actionability.unknown, "string", undefined); + } + + const name = readErrorName(error); + if (isNativeJsExceptionName(name)) { + return toActionability(actionability.internalPanic, "error", name, error); + } + + return toActionability(actionability.unknown, "error", name, error); +} + +export function classifyCliCauseActionability(cause: Cause.Cause): CliErrorActionability { + const error = Option.getOrElse(Cause.findErrorOption(cause), () => Cause.squash(cause)); + return classifyCliErrorActionability(error); +} diff --git a/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts new file mode 100644 index 0000000000..1fd966e1ed --- /dev/null +++ b/apps/cli/src/shared/telemetry/error-actionability.unit.test.ts @@ -0,0 +1,371 @@ +import { Cause, Data } from "effect"; +import { describe, expect, it } from "vitest"; +import { + actionability, + type CliErrorActionabilityDeclaration, + classifyCliCauseActionability, + classifyCliErrorActionability, + CliErrorActionabilityMetricDefinitions, + ErrorActionabilityId, + statusCodeActionability, +} from "./error-actionability.ts"; + +class DeclaredError extends Data.TaggedError("DeclaredError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.authLogin; + } +} + +class DeclaredStatusError extends Data.TaggedError("DeclaredStatusError")<{ + readonly status: number; + readonly upgradeSuggested?: boolean; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status, { upgradeSuggested: this.upgradeSuggested }); + } +} + +class UndeclaredError extends Data.TaggedError("UndeclaredError")<{ + readonly message: string; +}> {} + +class DeclaredNoSuggestionError extends Data.TaggedError("DeclaredNoSuggestionError")<{ + readonly message: string; + readonly suggestion?: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.dbFinding; + } +} + +describe("classifyCliErrorActionability", () => { + it("uses the declaration co-located on the error class", () => { + expect( + classifyCliErrorActionability(new DeclaredError({ message: "raw secret text" })), + ).toEqual({ + error_kind: "user_actionable", + error_category: "auth", + error_fingerprint: "tag:DeclaredError", + has_suggestion: true, + suggestion_type: "login", + suggested_command: "supabase login", + }); + }); + + it("lets instance-dependent declarations branch on typed fields", () => { + const auth = classifyCliErrorActionability(new DeclaredStatusError({ status: 401 })); + expect(auth.error_category).toBe("auth"); + expect(auth.error_fingerprint).toBe("tag:DeclaredStatusError:auth"); + + const gated = classifyCliErrorActionability( + new DeclaredStatusError({ status: 404, upgradeSuggested: true }), + ); + expect(gated.error_category).toBe("plan_limit"); + expect(gated.suggestion_type).toBe("upgrade_plan"); + expect(gated.error_fingerprint).toBe("tag:DeclaredStatusError:plan_limit"); + + const status = classifyCliErrorActionability(new DeclaredStatusError({ status: 500 })); + expect(status.error_kind).toBe("external_service"); + expect(status.error_category).toBe("api_status"); + expect(status.error_fingerprint).toBe("tag:DeclaredStatusError:api_status"); + }); + + it("classifies undeclared tagged errors as unknown with a sanitized fingerprint", () => { + const result = classifyCliErrorActionability(new UndeclaredError({ message: "boom" })); + expect(result.error_kind).toBe("unknown"); + expect(result.error_fingerprint).toBe("tag:UndeclaredError"); + }); + + it("classifies external stack build errors by structured reason", () => { + const invalidConfig = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "imgproxy requires storage to be enabled", + reason: "invalid_config", + }); + expect(invalidConfig.error_category).toBe("invalid_config"); + expect(invalidConfig.error_fingerprint).toBe("tag:StackBuildError:invalid_config"); + + const assetPreparation = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "Failed to prepare stack assets", + reason: "asset_preparation", + }); + expect(assetPreparation.error_kind).toBe("external_service"); + expect(assetPreparation.error_fingerprint).toBe("tag:StackBuildError:asset_preparation"); + + const internal = classifyCliErrorActionability({ _tag: "StackBuildError", detail: "bug" }); + expect(internal.error_kind).toBe("internal_bug"); + expect(internal.error_category).toBe("impossible_state"); + expect(internal.error_fingerprint).toBe("tag:StackBuildError:internal_build"); + }); + + it("splits docker pull failures from a stopped docker daemon", () => { + const daemonDown = classifyCliErrorActionability({ + _tag: "DockerPullError", + image: "postgres", + daemonDown: true, + }); + expect(daemonDown.error_category).toBe("docker_not_running"); + expect(daemonDown.suggestion_type).toBe("start_docker"); + + const pull = classifyCliErrorActionability({ _tag: "DockerPullError", image: "postgres" }); + expect(pull.error_kind).toBe("external_service"); + expect(pull.error_fingerprint).toBe("tag:DockerPullError:registry_pull"); + }); + + it("classifies http client errors by response presence and status", () => { + const auth = classifyCliErrorActionability({ + _tag: "HttpClientError", + response: { status: 401 }, + }); + expect(auth.error_category).toBe("auth"); + + const status = classifyCliErrorActionability({ + _tag: "HttpClientError", + response: { status: 503 }, + }); + expect(status.error_category).toBe("api_status"); + + const transport = classifyCliErrorActionability({ + _tag: "HttpClientError", + reason: { _tag: "TransportError" }, + }); + expect(transport.error_category).toBe("network"); + }); + + it("recurses into single-error ShowHelp wrappers", () => { + const single = classifyCliErrorActionability({ + _tag: "ShowHelp", + errors: [new DeclaredError({ message: "inner" })], + }); + expect(single.error_fingerprint).toBe("tag:DeclaredError"); + + const multiple = classifyCliErrorActionability({ + _tag: "ShowHelp", + errors: [{ _tag: "MissingOption" }, { _tag: "MissingOption" }], + }); + expect(multiple.error_category).toBe("invalid_input"); + expect(multiple.error_fingerprint).toBe("tag:ShowHelp"); + }); + + it("classifies StackError port allocation failures", () => { + const error = new Error("no free port"); + error.name = "StackError"; + Object.defineProperty(error, "code", { value: "PORT_ALLOCATION" }); + const result = classifyCliErrorActionability(error); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("error:StackError:port_allocation"); + + const other = new Error("other"); + other.name = "StackError"; + expect(classifyCliErrorActionability(other).error_kind).toBe("unknown"); + }); + + it("classifies the preserved tagged cause of a StackError wrapper", () => { + const wrapped = new Error("stack failure"); + wrapped.name = "StackError"; + Object.defineProperty(wrapped, "code", { value: "BUILD_ERROR" }); + Object.defineProperty(wrapped, "cause", { + value: { _tag: "StackBuildError", detail: "x", reason: "invalid_config" }, + }); + const result = classifyCliErrorActionability(wrapped); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("tag:StackBuildError:invalid_config"); + }); + + it("treats forbidden API statuses as account permission failures", () => { + const forbidden = classifyCliErrorActionability(new DeclaredStatusError({ status: 403 })); + expect(forbidden.error_kind).toBe("user_actionable"); + expect(forbidden.error_category).toBe("permission"); + expect(forbidden.error_fingerprint).toBe("tag:DeclaredStatusError:forbidden"); + + const gated = classifyCliErrorActionability( + new DeclaredStatusError({ status: 403, upgradeSuggested: true }), + ); + expect(gated.error_category).toBe("plan_limit"); + + const http = classifyCliErrorActionability({ + _tag: "HttpClientError", + response: { status: 403 }, + }); + expect(http.error_category).toBe("permission"); + }); + + it("classifies the preserved cause of stack wrapper errors", () => { + const daemonDown = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "Failed to prepare stack assets", + reason: "asset_preparation", + cause: { _tag: "DockerPullError", image: "postgres", daemonDown: true }, + }); + expect(daemonDown.error_category).toBe("docker_not_running"); + expect(daemonDown.error_fingerprint).toBe("tag:DockerPullError:docker_not_running"); + + const localFs = classifyCliErrorActionability({ + _tag: "DownloadError", + url: "filesystem error for /cache", + cause: { _tag: "PlatformError", reason: { _tag: "PermissionDenied" } }, + }); + expect(localFs.error_kind).toBe("user_actionable"); + expect(localFs.error_category).toBe("permission"); + + // An unclassifiable cause keeps the wrapper's own bucket. + const opaque = classifyCliErrorActionability({ + _tag: "DownloadError", + url: "https://example.com", + cause: new Error("boom"), + }); + expect(opaque.error_kind).toBe("external_service"); + expect(opaque.error_category).toBe("network"); + }); + + it("splits daemon start failures from other daemon RPC failures", () => { + const start = classifyCliErrorActionability({ + _tag: "UnixHttpClientError", + socketPath: "/tmp/daemon.sock", + path: "/start", + }); + expect(start.error_category).toBe("invalid_config"); + expect(start.suggested_command).toBe("supabase start"); + expect(start.error_fingerprint).toBe("tag:UnixHttpClientError:daemon_start"); + + const status = classifyCliErrorActionability({ + _tag: "UnixHttpClientError", + socketPath: "/tmp/daemon.sock", + path: "/status", + }); + expect(status.error_category).toBe("invalid_config"); + expect(status.suggested_command).toBe("supabase stop"); + expect(status.error_fingerprint).toBe("tag:UnixHttpClientError"); + }); + + it("classifies API client configuration failures as token problems", () => { + const result = classifyCliErrorActionability({ + _tag: "SupabaseApiConfigError", + message: "Missing access token.", + }); + expect(result.error_category).toBe("auth"); + expect(result.suggestion_type).toBe("set_env_var"); + }); + + it("caps cause-chain recursion instead of overflowing on cycles", () => { + const a: Record = { + _tag: "StackBuildError", + detail: "x", + reason: "asset_preparation", + }; + const b: Record = { + _tag: "StackBuildError", + detail: "y", + reason: "asset_preparation", + cause: a, + }; + a["cause"] = b; + const result = classifyCliErrorActionability(a); + expect(result.error_kind).toBe("unknown"); + expect(result.error_fingerprint).toBe("error:CauseChainLimit"); + + const self: Record = { _tag: "UserError" }; + self["cause"] = self; + expect(classifyCliErrorActionability(self).error_fingerprint).toBe("error:CauseChainLimit"); + }); + + it("classifies the user config cause of a reason-less StackBuildError", () => { + const result = classifyCliErrorActionability({ + _tag: "StackBuildError", + detail: "Failed to configure Edge Functions", + cause: { _tag: "ProjectConfigParseError", path: "supabase/config.toml" }, + }); + expect(result.error_category).toBe("invalid_config"); + expect(result.error_fingerprint).toBe("tag:ProjectConfigParseError"); + }); + + it("keeps HTTP download causes in the download bucket", () => { + // GitHub/CDN 401/403 during asset download must NOT hit the + // Management-API auth/permission policy of the HttpClientError adapter. + const forbidden = classifyCliErrorActionability({ + _tag: "DownloadError", + url: "https://github.com/releases/x", + cause: { _tag: "HttpClientError", response: { status: 403 } }, + }); + expect(forbidden.error_kind).toBe("external_service"); + expect(forbidden.error_category).toBe("network"); + expect(forbidden.error_fingerprint).toBe("tag:DownloadError"); + + const localFs = classifyCliErrorActionability({ + _tag: "DownloadError", + url: "filesystem error for /cache", + cause: { _tag: "PlatformError", reason: { _tag: "PermissionDenied" } }, + }); + expect(localFs.error_category).toBe("permission"); + }); + + it("does not treat Object.prototype members as external adapters", () => { + const result = classifyCliErrorActionability({ _tag: "constructor" }); + expect(result.error_kind).toBe("unknown"); + expect(result.error_fingerprint).toBe("tag:constructor"); + }); + + it("buckets native JS exceptions as internal panics", () => { + const result = classifyCliErrorActionability(new TypeError("x is not a function")); + expect(result.error_kind).toBe("internal_bug"); + expect(result.error_category).toBe("panic"); + expect(result.error_fingerprint).toBe("error:TypeError"); + }); + + it("reconciles has_suggestion with an instance-level rendered suggestion", () => { + const withSuggestion = classifyCliErrorActionability( + new DeclaredNoSuggestionError({ message: "bad row", suggestion: "do X" }), + ); + expect(withSuggestion.has_suggestion).toBe(true); + + const withoutSuggestion = classifyCliErrorActionability( + new DeclaredNoSuggestionError({ message: "bad row" }), + ); + expect(withoutSuggestion.has_suggestion).toBe(false); + + // A declaration-level true is never downgraded, even with no instance + // suggestion field. + const declaredTrue = classifyCliErrorActionability(new DeclaredError({ message: "x" })); + expect(declaredTrue.has_suggestion).toBe(true); + }); + + it("never leaks raw text into fingerprints for unknown failures", () => { + expect(classifyCliErrorActionability("raw failure text").error_fingerprint).toBe( + "string:unknown", + ); + const named = new Error("boom"); + named.name = "Weird Name With Spaces"; + expect(classifyCliErrorActionability(named).error_fingerprint).toBe("error:unknown"); + }); +}); + +describe("metric definitions", () => { + it("keeps the Q2 baseline definitions stable for reporting queries", () => { + // These ids are referenced by the PostHog reporting built in CLI-1562; + // changing them invalidates the Q2 baseline and must be deliberate. + expect(CliErrorActionabilityMetricDefinitions.strictRecovery.id).toBe( + "same_command_success_same_session", + ); + expect(CliErrorActionabilityMetricDefinitions.repeatError.id).toBe( + "same_command_same_error_same_session_before_success", + ); + expect(CliErrorActionabilityMetricDefinitions.internalUnknownBugRate.id).toBe( + "failed_commands_internal_bug_or_unknown", + ); + }); +}); + +describe("classifyCliCauseActionability", () => { + it("classifies the typed failure inside a cause", () => { + const cause = Cause.fail(new DeclaredError({ message: "inner" })); + expect(classifyCliCauseActionability(cause).error_category).toBe("auth"); + }); + + it("classifies defects", () => { + const cause = Cause.die(new TypeError("boom")); + expect(classifyCliCauseActionability(cause).error_category).toBe("panic"); + }); +}); diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts index 00803d7444..e8a613f273 100644 --- a/packages/stack/src/RemoteStack.ts +++ b/packages/stack/src/RemoteStack.ts @@ -1,7 +1,7 @@ import { ServiceNotFoundError, ServiceReadyError, type LogEntry } from "@supabase/process-compose"; import { Effect, Layer, Schema, Stream } from "effect"; import * as Sse from "effect/unstable/encoding/Sse"; -import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { Stack, StackInfoSchema } from "./Stack.ts"; import { StackServiceState, StackServiceStatusSchema } from "./StackServiceState.ts"; import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; @@ -86,11 +86,31 @@ function unixResponse(socketPath: string, path: string, init?: RequestInit) { ); } +/** + * Maps a `filterStatusOk` failure (the daemon responded, but with a non-2xx + * status) into a `UnixHttpClientError` so the CLI's error classifier sees a + * daemon-RPC failure instead of a raw Management-API-shaped `HttpClientError`. + */ +function dieOnNonOkStatus( + socketPath: string, + path: string, + effect: Effect.Effect, +) { + return effect.pipe( + Effect.mapError((cause) => new UnixHttpClientError({ socketPath, path, cause })), + Effect.orDie, + ); +} + /** Fetch JSON from the daemon, dying on HTTP errors. */ function fetchStatus(socketPath: string, path: string, method = "GET") { return Effect.gen(function* () { const response = yield* unixResponse(socketPath, path, { method }); - const okResponse = yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + const okResponse = yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); return yield* HttpClientResponse.schemaBodyJson(StatusResponseSchema)(okResponse).pipe( Effect.orDie, ); @@ -100,7 +120,11 @@ function fetchStatus(socketPath: string, path: string, method = "GET") { function fetchLogEntries(socketPath: string, path: string) { return Effect.gen(function* () { const response = yield* unixResponse(socketPath, path); - const okResponse = yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + const okResponse = yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); return yield* HttpClientResponse.schemaBodyJson(Schema.Array(LogEntrySchema))(okResponse).pipe( Effect.orDie, ); @@ -212,31 +236,47 @@ export const RemoteStack = { start: () => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, "/start", { method: "POST" }); - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + const path = "/start"; + const response = yield* unixResponse(socketPath, path, { method: "POST" }); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), stop: () => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, "/stop", { method: "POST" }); - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + const path = "/stop"; + const response = yield* unixResponse(socketPath, path, { method: "POST" }); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), dispose: () => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, "/stop", { method: "POST" }); - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + const path = "/stop"; + const response = yield* unixResponse(socketPath, path, { method: "POST" }); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), startService: (name: string) => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, `/services/${name}/start`, { + const path = `/services/${name}/start`; + const response = yield* unixResponse(socketPath, path, { method: "POST", }); if (response.status === 404) { @@ -248,48 +288,59 @@ export const RemoteStack = { ).pipe(Effect.orDie); return yield* new ServiceReadyError({ name, reason: body.error }); } - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), stopService: (name: string) => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, `/services/${name}/stop`, { + const path = `/services/${name}/stop`; + const response = yield* unixResponse(socketPath, path, { method: "POST", }); if (response.status === 404) { return yield* new ServiceNotFoundError({ name }); } - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), restartService: (name: string) => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, `/services/${name}/restart`, { + const path = `/services/${name}/restart`; + const response = yield* unixResponse(socketPath, path, { method: "POST", }); if (response.status === 404) { return yield* new ServiceNotFoundError({ name }); } - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), reloadFunctions: (opts) => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse( - socketPath, - `/functions/reload${encodeSearchParams({ - envFile: opts?.envFile, - noVerifyJwt: - opts?.noVerifyJwt === undefined ? undefined : String(opts.noVerifyJwt), - })}`, - { method: "POST" }, - ); + const path = `/functions/reload${encodeSearchParams({ + envFile: opts?.envFile, + noVerifyJwt: + opts?.noVerifyJwt === undefined ? undefined : String(opts.noVerifyJwt), + })}`; + const response = yield* unixResponse(socketPath, path, { method: "POST" }); if (response.status === 404) { return yield* new ServiceNotFoundError({ name: "edge-runtime" }); } @@ -302,14 +353,19 @@ export const RemoteStack = { reason: body.error, }); } - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), reloadEdgeRuntime: (opts) => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse(socketPath, "/edge-runtime/reload", { + const path = "/edge-runtime/reload"; + const response = yield* unixResponse(socketPath, path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(opts), @@ -326,7 +382,11 @@ export const RemoteStack = { reason: body.error, }); } - yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.orDie); + yield* dieOnNonOkStatus( + socketPath, + path, + HttpClientResponse.filterStatusOk(response), + ); }), ), diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 9975ca4fb0..b064b0808f 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -372,6 +372,7 @@ export const validateResolvedConfig = ( return yield* Effect.fail( new StackBuildError({ detail: `mode "native" only supports postgres, auth, and postgrest. Disable ${enabledDockerOnly.join(", ")} or switch to "auto" or "docker".`, + reason: "invalid_config", }), ); } @@ -381,6 +382,7 @@ export const validateResolvedConfig = ( return yield* Effect.fail( new StackBuildError({ detail: "imgproxy requires storage to be enabled", + reason: "invalid_config", }), ); } @@ -389,6 +391,7 @@ export const validateResolvedConfig = ( return yield* Effect.fail( new StackBuildError({ detail: "vector requires analytics to be enabled", + reason: "invalid_config", }), ); } @@ -397,6 +400,7 @@ export const validateResolvedConfig = ( return yield* Effect.fail( new StackBuildError({ detail: "studio requires pgmeta to be enabled", + reason: "invalid_config", }), ); } diff --git a/packages/stack/src/StackLifecycleCoordinator.ts b/packages/stack/src/StackLifecycleCoordinator.ts index 0a8ba5bee1..cfeacc0897 100644 --- a/packages/stack/src/StackLifecycleCoordinator.ts +++ b/packages/stack/src/StackLifecycleCoordinator.ts @@ -254,6 +254,7 @@ export class StackLifecycleCoordinator extends Context.Service< new StackBuildError({ detail: "Failed to prepare stack assets", cause, + reason: "asset_preparation", }), ), ) diff --git a/packages/stack/src/StackPreparation.ts b/packages/stack/src/StackPreparation.ts index 1c3a856bee..5b75083e28 100644 --- a/packages/stack/src/StackPreparation.ts +++ b/packages/stack/src/StackPreparation.ts @@ -2,7 +2,7 @@ import { Cause, Data, Effect, Exit, Layer, Queue, Context, Stream } from "effect import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { BinaryResolver } from "./BinaryResolver.ts"; import type { ChecksumMismatchError } from "./errors.ts"; -import { DockerPullError } from "./errors.ts"; +import { DockerPullError, isDockerDaemonDownMessage } from "./errors.ts"; import type { ServiceResolution } from "./resolve.ts"; import { DEFAULT_VERSIONS, @@ -209,6 +209,7 @@ const pullImage = ( yield* callbacks?.onDownloadStart ?? Effect.void; const failures: PullAttemptFailure[] = []; + let spawnFailed = false; for (const image of images) { for ( @@ -219,6 +220,9 @@ const pullImage = ( const attempt = attemptIndex + 1; const result = yield* Effect.exit(runPullCommand(spawner, image)); if (Exit.isSuccess(result)) { + // A successful spawn proves the runtime is usable; an earlier + // transient spawn failure must not taint the final classification. + spawnFailed = false; if (result.value.exitCode === 0) { return image; } @@ -233,6 +237,10 @@ const pullImage = ( break; } } else { + // A failed effect (rather than a non-zero exit) means the container + // runtime could not be spawned at all — a local Docker setup + // problem, not a registry failure. + spawnFailed = true; const cause = Cause.squash(result.cause); const message = cause instanceof Error ? cause.message : String(cause); failures.push({ image, attempt, message }); @@ -258,6 +266,8 @@ const pullImage = ( image: images[0] ?? "unknown", detail: `Failed to pull Docker image from all registries. ${detail}`, cause: new Error(detail), + daemonDown: + spawnFailed || failures.some((failure) => isDockerDaemonDownMessage(failure.message)), }), ); }); diff --git a/packages/stack/src/errors.ts b/packages/stack/src/errors.ts index 71bafcb46f..94ae4c115e 100644 --- a/packages/stack/src/errors.ts +++ b/packages/stack/src/errors.ts @@ -20,11 +20,47 @@ export class DockerPullError extends Data.TaggedError("DockerPullError")<{ readonly image: string; readonly detail: string; readonly cause: unknown; + /** + * Whether the pull failed because the container runtime itself is unusable + * locally — the daemon is unreachable (detected from the runtime's output + * at the boundary where it is produced) or the docker binary could not be + * spawned at all. Consumers must branch on this instead of sniffing + * `detail` text. + */ + readonly daemonDown: boolean; }> {} +/** + * Whether a container runtime's output indicates the daemon itself is not + * running. This is the boundary vocabulary for `DockerPullError.daemonDown` + * and shared with the CLI's legacy docker-run layer so both paths agree on + * what "daemon down" looks like. + */ +export const isDockerDaemonDownMessage = (message: string): boolean => { + const normalized = message.toLowerCase(); + return ( + normalized.includes("cannot connect to the docker daemon") || + normalized.includes("docker daemon is not running") || + normalized.includes("docker desktop is not running") || + normalized.includes("is the docker daemon running") || + // Spawn succeeds but the socket is not accessible (e.g. a Linux user + // missing docker group membership) — a local setup problem, not a + // registry failure. + normalized.includes("permission denied while trying to connect to the docker daemon") + ); +}; + export class StackBuildError extends Data.TaggedError("StackBuildError")<{ readonly detail: string; readonly cause?: unknown; + /** + * Structured discriminant for consumers that need to distinguish failure + * classes without parsing `detail`: `invalid_config` for user-fixable + * configuration problems, `asset_preparation` for download/registry + * failures while preparing stack assets. Absent for internal invariant + * violations. + */ + readonly reason?: "invalid_config" | "asset_preparation"; }> {} export class PortConflictError extends Data.TaggedError("PortConflictError")<{ @@ -54,6 +90,7 @@ export function toStackError(err: unknown): StackError { return new StackError({ code: "SERVICE_NOT_FOUND", message: taggedMessage, + cause: err, }); case "StackBuildError": return new StackError({ diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 087b40503e..3645f939ac 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -26,3 +26,4 @@ export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; export type { ReadyOptions, StackHandle } from "./createStack.ts"; export type { FunctionsConfig, FunctionsRuntimeConfig } from "./functions.ts"; export { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; +export { isDockerDaemonDownMessage } from "./errors.ts";