Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 9 additions & 11 deletions apps/cli/src/commands/branches/create/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,11 @@

## API Routes

| Method | Path | Auth | Request body | Response (used fields) |
| ------ | ----------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST` | `/v1/projects/{ref}/branches` | Bearer token | `{branch_name, is_default: false, git_branch?, region?, desired_instance_size?, persistent?, with_data?, notify_url?}` | full `BranchResponse` |
| `GET` | `/v1/projects/{ref}` (on 4xx gated) | Bearer token | none | `{organization_slug}` |
| `GET` | `/v1/organizations/{slug}/entitlements` (gated) | Bearer token | none | `[{feature: {key}, hasAccess, ...}]` |
| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region, …}]` — no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) |
| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) |
| Method | Path | Auth | Request body | Response (used fields) |
| ------ | ----------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST` | `/v1/projects/{ref}/branches` | Bearer token | `{branch_name, is_default: false, git_branch?, region?, desired_instance_size?, persistent?, with_data?, notify_url?}` | full `BranchResponse` |
| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region, …}]` — no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) |
| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) |

## Environment Variables

Expand All @@ -50,10 +48,10 @@

## Telemetry Events Fired

| Event | When | Notable properties / groups |
| ----------------------- | ----------------------------------------------- | ----------------------------------------------------------------- |
| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` whitelisted) |
| `cli_upgrade_suggested` | on 4xx with `branching_limit` entitlement gated | `{feature_key: "branching_limit", org_slug}` |
| Event | When | Notable properties / groups |
| ----------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- |
| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` whitelisted) |
| `cli_upgrade_suggested` | on a 4xx whose body is the `entitlement_required` envelope | `{feature_key: "branching_limit", org_slug}` |

## Output

Expand Down
2 changes: 0 additions & 2 deletions apps/cli/src/commands/branches/create/create.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { Layer } from "effect";
import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts";
import { compose } from "../../../command-internal/command-permissions/model.ts";
import { projectRefPicker } from "../../../config/remote-target.layer.ts";
import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts";
import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts";
import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts";
import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts";
Expand Down Expand Up @@ -112,7 +111,6 @@ export const branchesCreateCommand = Command.make("create", config).pipe(
noApiEffectFlags: ["region", "size", "persistent", "notify-url", "git-branch"],
},
projectRefPicker,
upgradeSuggestion,
linkedProjectCache,
),
),
Expand Down
26 changes: 12 additions & 14 deletions apps/cli/src/commands/branches/create/create.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,22 +104,20 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra
Effect.tapError(() => creating?.fail() ?? Effect.void),
// Runs the plan-gate check before mapping the error, even for a gated 4xx.
Effect.catch(
gateMapError(
{ projectRef: ref, featureKey: "branching_limit" },
(cause, upgradeSuggested) =>
mapCreateErrorRaw(cause).pipe(
Effect.catchTag("BranchesCreateUnexpectedStatusError", (mapped) =>
Effect.fail(
new BranchesCreateUnexpectedStatusError({
status: mapped.status,
body: mapped.body,
message: mapped.message,
upgradeSuggested,
entitlement: mapped.entitlement,
}),
),
gateMapError({ projectRef: ref }, (cause, upgradeSuggested) =>
mapCreateErrorRaw(cause).pipe(
Effect.catchTag("BranchesCreateUnexpectedStatusError", (mapped) =>
Effect.fail(
new BranchesCreateUnexpectedStatusError({
status: mapped.status,
body: mapped.body,
message: mapped.message,
upgradeSuggested,
entitlement: mapped.entitlement,
}),
),
),
),
),
),
);
Expand Down
64 changes: 25 additions & 39 deletions apps/cli/src/commands/branches/create/create.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,6 @@ function projectResponse() {
};
}

function entitlementResponse(opts: { readonly featureKey: string; readonly hasAccess: boolean }) {
return {
entitlements: [
{
feature: { key: opts.featureKey, type: "boolean" as const },
hasAccess: opts.hasAccess,
type: "boolean" as const,
config: { enabled: !opts.hasAccess },
},
],
};
}

const tempRoot = useTempWorkdir("supabase-branches-create-int-");

interface SetupOpts {
Expand All @@ -84,8 +71,6 @@ interface SetupOpts {
readonly response?: unknown;
readonly status?: number;
readonly network?: "fail";
readonly gated?: boolean;
readonly featureKey?: string;
/** Resolved `--yes`/`SUPABASE_YES` for the git-branch auto-name confirm. */
readonly yes?: boolean;
readonly stdinIsTty?: boolean;
Expand All @@ -97,24 +82,13 @@ interface SetupOpts {
function buildApiLayer(opts: SetupOpts) {
const status = opts.status ?? 201;
const body = opts.response ?? CREATED;
const featureKey = opts.featureKey ?? "branching_limit";
return mockCommandPlatformApi({
network: opts.network,
handler: (request) =>
Effect.sync(() => {
if (request.method === "POST" && request.url.includes("/branches")) {
return jsonResponse(request, status, body);
}
if (request.method === "GET" && request.url.endsWith(`/v1/projects/${VALID_REF}`)) {
return jsonResponse(request, 200, projectResponse());
}
if (request.method === "GET" && request.url.includes("/entitlements")) {
return jsonResponse(
request,
200,
entitlementResponse({ featureKey, hasAccess: !(opts.gated ?? false) }),
);
}
return jsonResponse(request, 200, null);
}),
});
Expand Down Expand Up @@ -439,22 +413,24 @@ describe("branches create integration", () => {
}).pipe(Effect.provide(layer));
});

it.effect("fires cli_upgrade_suggested with feature_key=branching_limit on 402 gated", () => {
const { layer, analytics, api } = setup({ status: 402, gated: true });
it.effect("makes one request and suggests no upgrade on a 4xx without the envelope", () => {
const { layer, out, analytics, api } = setup({
status: 400,
response: { message: "Invalid branch name" },
});
return Effect.gen(function* () {
yield* Effect.exit(branchesCreate({ ...baseFlags, name: Option.some("feat-x") }));
const exit = yield* Effect.exit(
branchesCreate({ ...baseFlags, name: Option.some("feat-x") }),
);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(classifyCliCauseActionability(exit.cause).error_category).not.toBe("plan_limit");
}
expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([
`POST https://api.supabase.com/v1/projects/${VALID_REF}/branches`,
`GET https://api.supabase.com/v1/projects/${VALID_REF}`,
`GET https://api.supabase.com/v1/organizations/${ORG_SLUG}/entitlements`,
]);
assertPermissionDrift({ command: "branches create", requests: api.requests });
expect(analytics.captured).toEqual([
{
event: "cli_upgrade_suggested",
properties: { feature_key: "branching_limit", org_slug: ORG_SLUG },
},
]);
expect(out.stderrText).not.toContain("Upgrade your plan:");
expect(analytics.captured).toHaveLength(0);
}).pipe(Effect.provide(layer));
});

Expand Down Expand Up @@ -532,7 +508,17 @@ describe("branches create integration", () => {
});

it.effect("writes linked-project cache + telemetry on the upgrade-suggest failure path", () => {
const { layer, telemetry, cache } = setupTracked({ status: 402, gated: true });
const { layer, telemetry, cache } = setupTracked({
status: 402,
response: {
message: "Branching is supported only on the Pro plan or above",
error: {
code: "entitlement_required",
feature: "branching_limit",
upgrade_url: "https://supabase.com/dashboard/org/env-org/billing",
},
},
});
return Effect.gen(function* () {
yield* Effect.exit(branchesCreate({ ...baseFlags, name: Option.some("feat-x") }));
expect(telemetry.flushed).toBe(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@
| ------ | ---------------------------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output |
| `POST` | `/v1/projects/{ref}/vanity-subdomain/activate` | Bearer token | `{ vanity_subdomain: string }` | `{ custom_domain: string }` |
| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body |
| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` (key `vanity_subdomain`) — upgrade-gate side-call on a 4xx with no plan-gate error in the body |
| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available |

## Environment Variables
Expand Down Expand Up @@ -50,7 +48,7 @@
| Event | When | Notable properties / groups |
| ----------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------ |
| `cli_command_executed` | post-run, success or failure (via wrapper); not fired when the `--experimental` gate is closed | `exit_code`, `duration_ms`, `flags` |
| `cli_upgrade_suggested` | gated 4xx responses only | `feature_key=vanity_subdomain`, `org_slug` |
| `cli_upgrade_suggested` | 4xx whose body is the `entitlement_required` envelope | `feature_key=vanity_subdomain`, `org_slug` |

## Output

Expand Down Expand Up @@ -81,7 +79,7 @@ One `result` event with the full response object.
open), even when the API call fails, but only on a cache miss with a token when
`GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed
gate writes nothing.
- On gated 4xx responses this command prints an upgrade suggestion and fires `cli_upgrade_suggested`.
- On a 4xx whose body is the `entitlement_required` envelope this command prints an upgrade suggestion and fires `cli_upgrade_suggested`.
- `--desired-subdomain` is required, but only enforced in the handler (after gate → login → ref
resolution) rather than at parse time, using the fixed wording
`required flag(s) "desired-subdomain" not set`. The gate/login/ref errors win over the missing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { withPermissions } from "../../../command-internal/command-permissions/c
import { compose } from "../../../command-internal/command-permissions/model.ts";
import { projectRefPicker } from "../../../config/remote-target.layer.ts";
import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts";
import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts";
import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts";
import { requireExperimental } from "../../../command-internal/experimental-gate.ts";
import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/go-output-flag.ts";
Expand Down Expand Up @@ -54,7 +53,6 @@ export const vanitySubdomainsActivateCommand = Command.make("activate", config).
},
],
},
upgradeSuggestion,
projectRefPicker,
linkedProjectCache,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ export const vanitySubdomainsActivate = Effect.fn("vanity-subdomains.activate")(
Effect.gen(function* () {
const upgradeSuggested = yield* suggestUpgrade({
projectRef: ref,
featureKey: "vanity_subdomain",
statusCode: mapped.status,
response: gateResponse(cause),
});
Expand Down
Loading
Loading