Skip to content
5 changes: 5 additions & 0 deletions .changeset/cli-delete-not-found.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"neon": minor
---

`databases delete` and `roles delete` report when the target is already gone (HTTP 204) instead of printing nothing. Table mode writes `ERROR:` on stderr and exits 1. JSON/YAML emit `{ deleted: false, message }` on stdout and exit 1.
71 changes: 70 additions & 1 deletion packages/cli/src/commands/databases.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { describe } from "vitest";
import { describe, expect } from "vitest";
import YAML from "yaml";

import { test } from "../test_utils/fixtures";

const missingDbMessage =
'Database "nosuchdb" not found on branch test_branch; nothing to delete.';

describe("databases", () => {
test("list", async ({ testCliCommand }) => {
await testCliCommand([
Expand Down Expand Up @@ -40,4 +44,69 @@ describe("databases", () => {
"test_branch",
]);
});

test("delete of a missing name reports not found and exits 1", async ({
testCliCommand,
}) => {
const { stdout } = await testCliCommand(
[
"databases",
"delete",
"nosuchdb",
"--project-id",
"test",
"--branch",
"test_branch",
],
{ code: 1, snapshot: false, stderr: "" },
);
expect(YAML.parse(stdout)).toEqual({
deleted: false,
message: missingDbMessage,
});
});

test("delete of a missing name prints ERROR in table mode", async ({
testCliCommand,
}) => {
const { stdout } = await testCliCommand(
[
"databases",
"delete",
"nosuchdb",
"--project-id",
"test",
"--branch",
"test_branch",
],
{
code: 1,
output: "table",
snapshot: false,
stderr: `ERROR: ${missingDbMessage}`,
},
);
expect(stdout).toBe("");
});

test("delete of a missing name emits json and exits 1", async ({
testCliCommand,
}) => {
const { stdout } = await testCliCommand(
[
"databases",
"delete",
"nosuchdb",
"--project-id",
"test",
"--branch",
"test_branch",
],
{ code: 1, output: "json", snapshot: false, stderr: "" },
);
expect(JSON.parse(stdout)).toEqual({
deleted: false,
message: missingDbMessage,
});
});
});
14 changes: 11 additions & 3 deletions packages/cli/src/commands/databases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { retryOnLock } from "../api.js";

import type { BranchScopeProps } from "../types.js";
import { branchIdFromProps, fillSingleProject } from "../utils/enrichers.js";
import {
branchNameForMissingDelete,
reportMissingDelete,
} from "../utils/missing_delete.js";
import { writer } from "../writer.js";

export const DATABASE_FIELDS = ["name", "owner_name", "created_at"] as const;
Expand Down Expand Up @@ -116,18 +120,22 @@ export const deleteDb = async (
props: BranchScopeProps & { database: string },
) => {
const branchId = await branchIdFromProps(props);
const { data } = await retryOnLock(() =>
const { data, status } = await retryOnLock(() =>
props.apiClient.deleteProjectBranchDatabase(
props.projectId,
branchId,
props.database,
),
);

// A 204 (database already gone) carries no body; only a 200 returns it.
if (data) {
// 204 is empty; some clients still parse that as `{}`, so require the record.
if (status === 200 && data?.database) {
writer(props).end(data.database, {
fields: DATABASE_FIELDS,
});
return;
}
const branchName = branchNameForMissingDelete(props, branchId);
const message = `Database "${props.database}" not found on branch ${branchName}; nothing to delete.`;
reportMissingDelete(props, message);
};
71 changes: 70 additions & 1 deletion packages/cli/src/commands/roles.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { describe } from "vitest";
import { describe, expect } from "vitest";
import YAML from "yaml";

import { test } from "../test_utils/fixtures";

const missingRoleMessage =
'Role "nosuchrole" not found on branch test_branch; nothing to delete.';

describe("roles", () => {
test("list", async ({ testCliCommand }) => {
await testCliCommand([
Expand Down Expand Up @@ -38,4 +42,69 @@ describe("roles", () => {
"test_branch",
]);
});

test("delete of a missing name reports not found and exits 1", async ({
testCliCommand,
}) => {
const { stdout } = await testCliCommand(
[
"roles",
"delete",
"nosuchrole",
"--project-id",
"test",
"--branch",
"test_branch",
],
{ code: 1, snapshot: false, stderr: "" },
);
expect(YAML.parse(stdout)).toEqual({
deleted: false,
message: missingRoleMessage,
});
});

test("delete of a missing name prints ERROR in table mode", async ({
testCliCommand,
}) => {
const { stdout } = await testCliCommand(
[
"roles",
"delete",
"nosuchrole",
"--project-id",
"test",
"--branch",
"test_branch",
],
{
code: 1,
output: "table",
snapshot: false,
stderr: `ERROR: ${missingRoleMessage}`,
},
);
expect(stdout).toBe("");
});

test("delete of a missing name emits json and exits 1", async ({
testCliCommand,
}) => {
const { stdout } = await testCliCommand(
[
"roles",
"delete",
"nosuchrole",
"--project-id",
"test",
"--branch",
"test_branch",
],
{ code: 1, output: "json", snapshot: false, stderr: "" },
);
expect(JSON.parse(stdout)).toEqual({
deleted: false,
message: missingRoleMessage,
});
});
});
14 changes: 11 additions & 3 deletions packages/cli/src/commands/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type yargs from "yargs";
import { retryOnLock } from "../api.js";
import type { BranchScopeProps } from "../types.js";
import { branchIdFromProps, fillSingleProject } from "../utils/enrichers.js";
import {
branchNameForMissingDelete,
reportMissingDelete,
} from "../utils/missing_delete.js";
import { writer } from "../writer.js";

const ROLES_FIELDS = ["name", "created_at"] as const;
Expand Down Expand Up @@ -93,17 +97,21 @@ export const deleteRole = async (
props: BranchScopeProps & { role: string },
) => {
const branchId = await branchIdFromProps(props);
const { data } = await retryOnLock(() =>
const { data, status } = await retryOnLock(() =>
props.apiClient.deleteProjectBranchRole(
props.projectId,
branchId,
props.role,
),
);
// A 204 (role already gone) carries no body; only a 200 returns the role.
if (data) {
// 204 is empty; some clients still parse that as `{}`, so require the record.
if (status === 200 && data?.role) {
writer(props).end(data.role, {
fields: ROLES_FIELDS,
});
return;
}
const branchName = branchNameForMissingDelete(props, branchId);
const message = `Role "${props.role}" not found on branch ${branchName}; nothing to delete.`;
reportMissingDelete(props, message);
};
41 changes: 41 additions & 0 deletions packages/cli/src/utils/missing_delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { BranchScopeProps, CommonProps } from "../types.js";
import { writer } from "../writer.js";
import { looksLikeBranchId } from "./formats.js";

/**
* Report a delete that returned HTTP 204. Table mode throws so the CLI
* prints ERROR on stderr; JSON/YAML keep the payload on stdout, so we
* set exitCode instead of throwing.
*/
export const reportMissingDelete = (
props: Pick<CommonProps, "output">,
message: string,
): void => {
if (props.output === "json" || props.output === "yaml") {
writer(props).end(
{ deleted: false, message },
{ fields: ["deleted", "message"] },
);
process.exitCode = 1;
return;
}
throw new Error(message);
};

/**
* Label for a missing-delete message from values already in hand. A second
* list after 204 would be able to replace the not-found result.
*/
export const branchNameForMissingDelete = (
props: BranchScopeProps,
branchId: string,
): string => {
const ref =
"branch" in props && typeof props.branch === "string"
? props.branch
: undefined;
if (ref !== undefined && !looksLikeBranchId(ref)) {
return ref;
}
return ref ?? branchId;
};
Loading