@batadata/cli 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.js CHANGED
@@ -14,7 +14,7 @@ export async function request(method, path, options = {}) {
14
14
  }
15
15
  const headers = {
16
16
  "Content-Type": "application/json",
17
- "User-Agent": "@batadata/cli 0.1.3",
17
+ "User-Agent": "@batadata/cli 0.1.4",
18
18
  };
19
19
  if (options.token) {
20
20
  headers["Authorization"] = `Bearer ${options.token}`;
@@ -36,26 +36,46 @@ function formatDate(iso) {
36
36
  * when the compute is ready to serve a query without a cold start.
37
37
  */
38
38
  function branchStatusLabel(b) {
39
- const base = b.computeStatus || b.status || "-";
39
+ const base = b.computeStatus || "-";
40
40
  if (b.ready === true)
41
41
  return `${base} ${colors.green("✓")}`;
42
42
  return base;
43
43
  }
44
44
  /**
45
- * Resolve a project's primary branch (the one to run a query against). Lists
46
- * branches the same way `db branches` does (GET /v1/projects/:id) and returns
47
- * the primary, falling back to the first branch. Returns null if the project
48
- * has no branches or the lookup failed.
45
+ * List a project's branches (GET /v1/projects/:id), each enriched by the server
46
+ * with computeStatus + ready. Returns null if the lookup failed.
49
47
  */
50
- async function getPrimaryBranch(projectId, token, teamId) {
48
+ async function listBranches(projectId, token, teamId) {
51
49
  const query = {};
52
50
  if (teamId)
53
51
  query.team_id = teamId;
54
52
  const res = await api.get(`/v1/projects/${projectId}`, token, query);
55
53
  if (!res.ok)
56
54
  return null;
57
- const list = res.data.branches ?? [];
58
- return list.find((b) => b.is_primary) ?? list[0] ?? null;
55
+ return { branches: res.data.branches ?? [], projectName: res.data.name };
56
+ }
57
+ /**
58
+ * Resolve a project's primary branch (the one to run a query against), falling
59
+ * back to the first branch. Returns null if the project has no branches or the
60
+ * lookup failed.
61
+ */
62
+ async function getPrimaryBranch(projectId, token, teamId) {
63
+ const result = await listBranches(projectId, token, teamId);
64
+ if (!result)
65
+ return null;
66
+ return result.branches.find((b) => b.isPrimary) ?? result.branches[0] ?? null;
67
+ }
68
+ /**
69
+ * Resolve a `--branch` reference — which may be a branch ID **or** a branch
70
+ * name — to a concrete branch. Agents naturally pass the name (the same string
71
+ * they used with `db branch create`), so accepting only an id was a footgun
72
+ * ("Branch not found"). Returns null if no branch matches.
73
+ */
74
+ async function resolveBranchRef(projectId, token, teamId, ref) {
75
+ const result = await listBranches(projectId, token, teamId);
76
+ if (!result)
77
+ return null;
78
+ return result.branches.find((b) => b.id === ref || b.name === ref) ?? null;
59
79
  }
60
80
  export async function connect() {
61
81
  // psql is an interactive session — there's no headless equivalent. Don't spawn
@@ -123,38 +143,31 @@ export async function branches() {
123
143
  emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
124
144
  }
125
145
  const s = jsonMode ? null : spinner("Fetching branches");
126
- const query = {};
127
- if (config.defaultTeam)
128
- query.team_id = config.defaultTeam;
129
- const res = await api.get(`/v1/projects/${projectId}`, token, query);
146
+ const result = await listBranches(projectId, token, config.defaultTeam);
130
147
  s?.stop();
131
- if (!res.ok) {
132
- emitError(res.status === 401 || res.status === 403 ? "INVALID_KEY"
133
- : res.status === 404 ? "NO_PROJECT"
134
- : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
135
- : "CLI_ERROR", apiError(res, "Failed to fetch branches"), "");
148
+ if (!result) {
149
+ emitError("API_UNAVAILABLE", "Failed to fetch branches.", "");
136
150
  }
137
- const branchList = res.data.branches || [];
151
+ const branchList = result.branches;
138
152
  if (jsonMode) {
139
153
  json({
140
154
  project_id: projectId,
141
- project_name: res.data.name,
155
+ project_name: result.projectName,
142
156
  branches: branchList.map((b) => ({
143
157
  id: b.id,
144
158
  name: b.name,
145
- is_primary: b.is_primary,
146
- status: b.status,
159
+ is_primary: b.isPrimary ?? false,
147
160
  // Compute readiness — what agents poll on between a create/cold start
148
161
  // and a successful query.
149
162
  computeStatus: b.computeStatus ?? null,
150
163
  ready: b.ready ?? null,
151
- created_at: b.created_at,
164
+ created_at: b.createdAt ?? null,
152
165
  })),
153
166
  count: branchList.length,
154
167
  });
155
168
  return;
156
169
  }
157
- heading(`Branches — ${res.data.name}`);
170
+ heading(`Branches — ${result.projectName}`);
158
171
  if (branchList.length === 0) {
159
172
  log(` ${colors.dim("No branches found.")}`);
160
173
  log();
@@ -162,11 +175,11 @@ export async function branches() {
162
175
  }
163
176
  table(["NAME", "PRIMARY", "STATUS", "CREATED"], branchList.map((b) => [
164
177
  b.name,
165
- b.is_primary ? colors.green("yes") : "-",
166
- // Prefer the live compute lifecycle (computeStatus); fall back to the
167
- // branch row status. Mark ready with a check so the column is scannable.
178
+ b.isPrimary ? colors.green("yes") : "-",
179
+ // Live compute lifecycle (computeStatus), with a check once ready so the
180
+ // column is scannable.
168
181
  branchStatusLabel(b),
169
- formatDate(b.created_at),
182
+ formatDate(b.createdAt ?? ""),
170
183
  ]));
171
184
  log();
172
185
  }
@@ -275,10 +288,11 @@ export async function studio() {
275
288
  openBrowser(studioUrl);
276
289
  }
277
290
  /**
278
- * Pull a `--branch <id>` / `--branch=<id>` flag out of the query args and return
279
- * the explicit branch id (if any) plus the remaining args, which join into the
280
- * SQL string. Keeps `db query` order-independent (the flag can sit before or
281
- * after the SQL) and consistent with how global flags are parsed.
291
+ * Pull a `--branch <ref>` / `--branch=<ref>` flag out of the query args and
292
+ * return the explicit branch ref an id OR a name, resolved later plus the
293
+ * remaining args, which join into the SQL string. Keeps `db query`
294
+ * order-independent (the flag can sit before or after the SQL) and consistent
295
+ * with how global flags are parsed.
282
296
  */
283
297
  function parseBranchFlag(args) {
284
298
  let branchId;
@@ -311,11 +325,22 @@ export async function query(args = []) {
311
325
  emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
312
326
  }
313
327
  const s = jsonMode ? null : spinner("Running query");
314
- // Target branch: an explicit --branch <id> wins; otherwise resolve the
315
- // project's primary branch. /v1/sql/execute is keyed on branch_id, not
316
- // project. (The old code POSTed to /v1/query/:projectId, which 404s.)
317
- let branchId_ = branchId;
318
- if (!branchId_) {
328
+ // Target branch: an explicit --branch <id-or-name> wins; otherwise resolve
329
+ // the project's primary branch. /v1/sql/execute is keyed on branch_id, not
330
+ // project. (The old code POSTed to /v1/query/:projectId, which 404s.) We
331
+ // resolve the --branch value against the project's branch list so a NAME
332
+ // works too — sending a name straight through as branch_id 404'd ("Branch
333
+ // not found").
334
+ let branchId_;
335
+ if (branchId) {
336
+ const branch = await resolveBranchRef(projectId, token, config.defaultTeam, branchId);
337
+ if (!branch) {
338
+ s?.stop();
339
+ emitError("BRANCH_NOT_FOUND", `Branch "${branchId}" not found in this project.`, "List branches with: bata db branches --json");
340
+ }
341
+ branchId_ = branch.id;
342
+ }
343
+ else {
319
344
  const branch = await getPrimaryBranch(projectId, token, config.defaultTeam);
320
345
  if (!branch) {
321
346
  s?.stop();
@@ -401,9 +426,9 @@ function dbHelp(sub) {
401
426
  log(` ${colors.bold("bata db query")} — run a SQL query against a branch`);
402
427
  log();
403
428
  usage('bata db query "SELECT 1"');
404
- usage("bata db query <sql> [--branch <branch_id>] [--json]");
429
+ usage("bata db query <sql> [--branch <id-or-name>] [--json]");
405
430
  log();
406
- note("--branch <branch_id> Target a specific branch (default: the project's primary)");
431
+ note("--branch <id-or-name> Target a specific branch by id OR name (default: the project's primary)");
407
432
  note("--json Emit rows as JSON objects + row_count");
408
433
  note("Cold-start note: a just-created/idle branch may answer with exit 6");
409
434
  note("(retryable) while its compute wakes — retry in a few seconds.");
@@ -447,7 +472,7 @@ function dbHelp(sub) {
447
472
  usage("bata db branch create Create a new branch");
448
473
  usage("bata db branch delete Delete a branch");
449
474
  usage("bata db studio Open the table browser");
450
- usage("bata db query <sql> Run a SQL query (--branch <id> to target a branch)");
475
+ usage("bata db query <sql> Run a SQL query (--branch <id-or-name> to target a branch)");
451
476
  log();
452
477
  note("Add --help to any subcommand for details (e.g. bata db query --help).");
453
478
  }
@@ -6,7 +6,17 @@ const { get, post } = vi.hoisted(() => ({
6
6
  get: vi.fn(async () => ({
7
7
  ok: true,
8
8
  status: 200,
9
- data: { id: "proj_1", name: "demo", branches: [{ id: "br_primary", name: "main", is_primary: true, status: "ready" }] },
9
+ // Shape matches GET /v1/projects/:id: camelCase branch fields enriched
10
+ // with computeStatus + ready. `feature` is a second, non-primary branch
11
+ // so name/id resolution has something to match.
12
+ data: {
13
+ id: "proj_1",
14
+ name: "demo",
15
+ branches: [
16
+ { id: "br_primary", name: "main", isPrimary: true, computeStatus: "active", ready: true },
17
+ { id: "br_feature", name: "feature", isPrimary: false, computeStatus: "idle", ready: false },
18
+ ],
19
+ },
10
20
  })),
11
21
  post: vi.fn(async () => ({ ok: true, status: 200, data: { columns: ["n"], rows: [{ n: "1" }], rowCount: 1 } })),
12
22
  }));
@@ -94,16 +104,26 @@ describe("db query — --help short-circuits before any network call", () => {
94
104
  });
95
105
  });
96
106
  describe("db query — branch targeting", () => {
97
- it("--branch <id> sets branch_id in the request body and skips primary lookup", async () => {
98
- await exitCodeOf(() => query(["SELECT 1", "--branch", "br_explicit"]));
107
+ it("--branch <id> resolves to that branch and sets branch_id", async () => {
108
+ await exitCodeOf(() => query(["SELECT 1", "--branch", "br_feature"]));
99
109
  expect(post).toHaveBeenCalledTimes(1);
100
- expect(post).toHaveBeenCalledWith("/v1/sql/execute", expect.objectContaining({ branch_id: "br_explicit", query: "SELECT 1" }), "test-token");
101
- // Explicit branch → no need to resolve the primary.
102
- expect(get).not.toHaveBeenCalled();
110
+ expect(post).toHaveBeenCalledWith("/v1/sql/execute", expect.objectContaining({ branch_id: "br_feature", query: "SELECT 1" }), "test-token");
111
+ });
112
+ it("--branch <name> resolves the NAME to the branch id (was 'Branch not found')", async () => {
113
+ await exitCodeOf(() => query(["SELECT 1", "--branch", "feature"]));
114
+ expect(post).toHaveBeenCalledTimes(1);
115
+ expect(post).toHaveBeenCalledWith("/v1/sql/execute",
116
+ // Resolved the NAME "feature" → its id "br_feature".
117
+ expect.objectContaining({ branch_id: "br_feature", query: "SELECT 1" }), "test-token");
103
118
  });
104
- it("--branch=<id> (equals form) is also honored", async () => {
105
- await exitCodeOf(() => query(["--branch=br_eq", "SELECT 2"]));
106
- expect(post).toHaveBeenCalledWith("/v1/sql/execute", expect.objectContaining({ branch_id: "br_eq", query: "SELECT 2" }), "test-token");
119
+ it("--branch=<name> (equals form) is also resolved", async () => {
120
+ await exitCodeOf(() => query(["--branch=feature", "SELECT 2"]));
121
+ expect(post).toHaveBeenCalledWith("/v1/sql/execute", expect.objectContaining({ branch_id: "br_feature", query: "SELECT 2" }), "test-token");
122
+ });
123
+ it("--branch <unknown> exits 5 (not-found) and never runs a query", async () => {
124
+ const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "does-not-exist"]));
125
+ expect(code).toBe(5);
126
+ expect(post).not.toHaveBeenCalled();
107
127
  });
108
128
  it("without --branch, resolves the project's primary branch (default behavior)", async () => {
109
129
  await exitCodeOf(() => query(["SELECT 1"]));
@@ -118,7 +138,7 @@ describe("db query — retryable cold-start mapping (exit 6)", () => {
118
138
  status: 503,
119
139
  data: { error: "compute is starting", code: "COMPUTE_STARTING" },
120
140
  });
121
- const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "br_cold"]));
141
+ const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "feature"]));
122
142
  expect(code).toBe(6);
123
143
  });
124
144
  it("REGRESSION GUARD: a non-5xx status carrying { code: COMPUTE_STARTING } still maps to exit 6", async () => {
@@ -130,7 +150,7 @@ describe("db query — retryable cold-start mapping (exit 6)", () => {
130
150
  status: 425,
131
151
  data: { error: "compute is starting", code: "COMPUTE_STARTING" },
132
152
  });
133
- const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "br_cold"]));
153
+ const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "feature"]));
134
154
  expect(code).toBe(6);
135
155
  });
136
156
  it("a connection-refused error body maps to exit 6", async () => {
@@ -139,12 +159,12 @@ describe("db query — retryable cold-start mapping (exit 6)", () => {
139
159
  status: 0,
140
160
  data: { error: "connect ECONNREFUSED 127.0.0.1:5432" },
141
161
  });
142
- const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "br_cold"]));
162
+ const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "feature"]));
143
163
  expect(code).toBe(6);
144
164
  });
145
165
  it("a generic 5xx maps to exit 6", async () => {
146
166
  post.mockResolvedValueOnce({ ok: false, status: 502, data: { error: "bad gateway" } });
147
- const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "br_cold"]));
167
+ const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "feature"]));
148
168
  expect(code).toBe(6);
149
169
  });
150
170
  it("a genuine SQL syntax error (200 with error field) stays exit 1, NOT retryable", async () => {
@@ -153,7 +173,7 @@ describe("db query — retryable cold-start mapping (exit 6)", () => {
153
173
  status: 200,
154
174
  data: { error: 'syntax error at or near "SELCT"' },
155
175
  });
156
- const code = await exitCodeOf(() => query(["SELCT 1", "--branch", "br_ok"]));
176
+ const code = await exitCodeOf(() => query(["SELCT 1", "--branch", "feature"]));
157
177
  expect(code).toBe(1);
158
178
  });
159
179
  it("a 400 bad-input SQL error stays exit 1, NOT retryable", async () => {
@@ -162,12 +182,12 @@ describe("db query — retryable cold-start mapping (exit 6)", () => {
162
182
  status: 400,
163
183
  data: { error: "relation does not exist" },
164
184
  });
165
- const code = await exitCodeOf(() => query(["SELECT * FROM nope", "--branch", "br_ok"]));
185
+ const code = await exitCodeOf(() => query(["SELECT * FROM nope", "--branch", "feature"]));
166
186
  expect(code).toBe(1);
167
187
  });
168
188
  it("a 401 stays auth (exit 4), not retryable", async () => {
169
189
  post.mockResolvedValueOnce({ ok: false, status: 401, data: { error: "invalid key" } });
170
- const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "br_ok"]));
190
+ const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "feature"]));
171
191
  expect(code).toBe(4);
172
192
  });
173
193
  });
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import { parseGlobalFlags } from "./args.js";
15
15
  import { isJsonMode } from "./config.js";
16
16
  import { colors, log, banner } from "./utils/logger.js";
17
17
  import { exitCodeFor, isRetryable } from "./utils/errors.js";
18
- const VERSION = "0.1.3";
18
+ const VERSION = "0.1.4";
19
19
  function help() {
20
20
  banner();
21
21
  log(` ${colors.bold("Usage")}`);
@@ -124,6 +124,6 @@ export function kvList(items) {
124
124
  // Banner
125
125
  export function banner() {
126
126
  log();
127
- log(` ${colors.cyan(colors.bold("BataDB"))} ${colors.dim("v0.1.3")} ${colors.dim("— serverless Postgres platform")}`);
127
+ log(` ${colors.cyan(colors.bold("BataDB"))} ${colors.dim("v0.1.4")} ${colors.dim("— serverless Postgres platform")}`);
128
128
  log();
129
129
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@batadata/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "CLI for BataDB — serverless Postgres platform",
5
5
  "bin": {
6
6
  "bata": "./dist/index.js"