@batadata/cli 0.1.1 → 0.1.3

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.
@@ -2,8 +2,9 @@ import { execSync, spawn } from "node:child_process";
2
2
  import { api, apiError } from "../api.js";
3
3
  import { requireToken, loadConfig, isJsonMode } from "../config.js";
4
4
  import { colors, log, json, error, spinner, table, heading, info as logInfo } from "../utils/logger.js";
5
- import { prompt, confirm } from "../utils/prompts.js";
5
+ import { prompt, confirmDestructive } from "../utils/prompts.js";
6
6
  import { openBrowser } from "../utils/open.js";
7
+ import { emitError, isRetryable } from "../utils/errors.js";
7
8
  async function getConnectionInfo(projectId, token) {
8
9
  // reveal=true so the returned string is actually usable (the owner is asking).
9
10
  const res = await api.get(`/v1/connection-info/${projectId}`, token, { reveal: "true" });
@@ -29,20 +30,50 @@ function formatDate(iso) {
29
30
  const d = new Date(iso);
30
31
  return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
31
32
  }
33
+ /**
34
+ * Human-readable STATUS cell for a branch. Prefers the live compute lifecycle
35
+ * (computeStatus) over the static branch row status, and appends a green check
36
+ * when the compute is ready to serve a query without a cold start.
37
+ */
38
+ function branchStatusLabel(b) {
39
+ const base = b.computeStatus || b.status || "-";
40
+ if (b.ready === true)
41
+ return `${base} ${colors.green("✓")}`;
42
+ return base;
43
+ }
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.
49
+ */
50
+ async function getPrimaryBranch(projectId, token, teamId) {
51
+ const query = {};
52
+ if (teamId)
53
+ query.team_id = teamId;
54
+ const res = await api.get(`/v1/projects/${projectId}`, token, query);
55
+ if (!res.ok)
56
+ return null;
57
+ const list = res.data.branches ?? [];
58
+ return list.find((b) => b.is_primary) ?? list[0] ?? null;
59
+ }
32
60
  export async function connect() {
61
+ // psql is an interactive session — there's no headless equivalent. Don't spawn
62
+ // it in --json or non-TTY contexts; point agents at the headless surfaces.
63
+ if (isJsonMode() || !process.stdin.isTTY) {
64
+ emitError("INTERACTIVE_ONLY", "bata db connect opens an interactive psql session and can't run headlessly.", "Use `bata db url` for a connection string or `bata db query <sql>` for headless execution.");
65
+ }
33
66
  const token = requireToken();
34
67
  const config = loadConfig();
35
68
  const projectId = config.defaultProject;
36
69
  if (!projectId) {
37
- error("No default project. Run bata projects create or set one with bata projects info <id>.");
38
- process.exit(1);
70
+ emitError("NO_PROJECT", "No default project.", "Run bata projects create or set one with bata projects info <id>.");
39
71
  }
40
72
  const s = spinner("Fetching connection info");
41
73
  const conn = await getConnectionInfo(projectId, token);
42
74
  s.stop();
43
75
  if (!conn || !conn.connection_uri) {
44
- error("Could not fetch connection string for this project.");
45
- process.exit(1);
76
+ emitError("NOT_FOUND", "Could not fetch connection string for this project.", "");
46
77
  }
47
78
  log();
48
79
  logInfo(`Connecting to project ${colors.cyan(projectId)}`);
@@ -70,17 +101,11 @@ export async function url() {
70
101
  const config = loadConfig();
71
102
  const projectId = config.defaultProject;
72
103
  if (!projectId) {
73
- error("No default project set.");
74
- process.exit(1);
104
+ emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
75
105
  }
76
106
  const conn = await getConnectionInfo(projectId, token);
77
107
  if (!conn || !conn.connection_uri) {
78
- const msg = "Could not fetch connection string.";
79
- if (isJsonMode())
80
- json({ error: msg });
81
- else
82
- error(msg);
83
- process.exit(1);
108
+ emitError("NOT_FOUND", "Could not fetch connection string.", "");
84
109
  }
85
110
  if (isJsonMode()) {
86
111
  json({ direct: conn.connection_uri, pooled: conn.pooled_uri ?? null });
@@ -95,12 +120,7 @@ export async function branches() {
95
120
  const jsonMode = isJsonMode();
96
121
  const projectId = config.defaultProject;
97
122
  if (!projectId) {
98
- const msg = "No default project set.";
99
- if (jsonMode)
100
- json({ error: msg });
101
- else
102
- error(msg);
103
- process.exit(1);
123
+ emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
104
124
  }
105
125
  const s = jsonMode ? null : spinner("Fetching branches");
106
126
  const query = {};
@@ -109,11 +129,10 @@ export async function branches() {
109
129
  const res = await api.get(`/v1/projects/${projectId}`, token, query);
110
130
  s?.stop();
111
131
  if (!res.ok) {
112
- if (jsonMode)
113
- json({ error: apiError(res, "Failed to fetch branches") });
114
- else
115
- error(apiError(res, "Failed to fetch project branches"));
116
- process.exit(1);
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"), "");
117
136
  }
118
137
  const branchList = res.data.branches || [];
119
138
  if (jsonMode) {
@@ -125,6 +144,10 @@ export async function branches() {
125
144
  name: b.name,
126
145
  is_primary: b.is_primary,
127
146
  status: b.status,
147
+ // Compute readiness — what agents poll on between a create/cold start
148
+ // and a successful query.
149
+ computeStatus: b.computeStatus ?? null,
150
+ ready: b.ready ?? null,
128
151
  created_at: b.created_at,
129
152
  })),
130
153
  count: branchList.length,
@@ -140,25 +163,33 @@ export async function branches() {
140
163
  table(["NAME", "PRIMARY", "STATUS", "CREATED"], branchList.map((b) => [
141
164
  b.name,
142
165
  b.is_primary ? colors.green("yes") : "-",
143
- b.status || "-",
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.
168
+ branchStatusLabel(b),
144
169
  formatDate(b.created_at),
145
170
  ]));
146
171
  log();
147
172
  }
148
173
  export async function branchCreate(name) {
174
+ const jsonMode = isJsonMode();
149
175
  const token = requireToken();
150
176
  const config = loadConfig();
151
177
  const projectId = config.defaultProject;
152
178
  if (!projectId) {
153
- error("No default project set.");
154
- process.exit(1);
179
+ emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
155
180
  }
156
- const branchName = name || (await prompt("Branch name"));
181
+ // Don't block on an interactive prompt headlessly.
182
+ let branchName = name;
157
183
  if (!branchName) {
158
- error("Branch name is required.");
159
- process.exit(1);
184
+ if (jsonMode || !process.stdin.isTTY) {
185
+ emitError("MISSING_ARG", "Branch name is required.", "Usage: bata db branch create <name>");
186
+ }
187
+ branchName = await prompt("Branch name");
188
+ }
189
+ if (!branchName) {
190
+ emitError("MISSING_ARG", "Branch name is required.", "Usage: bata db branch create <name>");
160
191
  }
161
- const s = spinner(`Creating branch ${colors.cyan(branchName)}`);
192
+ const s = jsonMode ? null : spinner(`Creating branch ${colors.cyan(branchName)}`);
162
193
  const body = {
163
194
  name: branchName,
164
195
  project_id: projectId,
@@ -166,54 +197,66 @@ export async function branchCreate(name) {
166
197
  if (config.defaultTeam)
167
198
  body.team_id = config.defaultTeam;
168
199
  const res = await api.post("/v1/branches", body, token);
169
- s.stop();
200
+ s?.stop();
170
201
  if (!res.ok) {
171
- error(apiError(res, "Failed to create branch"));
172
- process.exit(1);
202
+ emitError(res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE" : "CLI_ERROR", apiError(res, "Failed to create branch"), "");
203
+ }
204
+ if (jsonMode) {
205
+ json({
206
+ branch: { id: res.data.id, name: res.data.name ?? branchName, project_id: projectId },
207
+ // The branch row exists immediately, but its compute may still be
208
+ // provisioning — poll `db branches` for status before connecting.
209
+ ready: false,
210
+ poll: "bata db branches --json",
211
+ });
212
+ return;
173
213
  }
174
214
  log();
175
215
  log(` ${colors.green(">")} Branch ${colors.cyan(branchName)} created`);
216
+ log(` ${colors.dim("Poll readiness with")} ${colors.cyan("bata db branches")}`);
176
217
  log();
177
218
  }
178
219
  export async function branchDelete(name) {
220
+ const jsonMode = isJsonMode();
179
221
  const token = requireToken();
180
222
  const config = loadConfig();
181
223
  const projectId = config.defaultProject;
182
224
  if (!projectId) {
183
- error("No default project set.");
184
- process.exit(1);
225
+ emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
185
226
  }
186
227
  if (!name) {
187
- error("Branch name is required. Usage: bata db branch delete <name>");
188
- process.exit(1);
228
+ emitError("MISSING_ARG", "Branch name is required.", "Usage: bata db branch delete <name>");
189
229
  }
190
- const ok = await confirm(`Delete branch ${colors.cyan(name)}?`, false);
230
+ // Skip the prompt headlessly (--yes / --json / no TTY) so agents and CI can
231
+ // actually delete — and never report success without deleting.
232
+ const ok = await confirmDestructive(`Delete branch ${colors.cyan(name)}?`);
191
233
  if (!ok) {
192
234
  log(" Aborted.");
193
235
  return;
194
236
  }
195
- const s = spinner(`Deleting branch ${name}`);
237
+ const s = jsonMode ? null : spinner(`Deleting branch ${name}`);
196
238
  // We need to find the branch ID first
197
239
  const query = {};
198
240
  if (config.defaultTeam)
199
241
  query.team_id = config.defaultTeam;
200
242
  const projRes = await api.get(`/v1/projects/${projectId}`, token, query);
201
243
  if (!projRes.ok || !projRes.data.branches) {
202
- s.stop();
203
- error("Failed to fetch branches.");
204
- process.exit(1);
244
+ s?.stop();
245
+ emitError(projRes.status >= 500 || projRes.status === 0 ? "API_UNAVAILABLE" : "CLI_ERROR", apiError(projRes, "Failed to fetch branches."), "");
205
246
  }
206
247
  const branch = projRes.data.branches.find((b) => b.name === name);
207
248
  if (!branch) {
208
- s.stop();
209
- error(`Branch "${name}" not found.`);
210
- process.exit(1);
249
+ s?.stop();
250
+ emitError("BRANCH_NOT_FOUND", `Branch "${name}" not found.`, "List branches with: bata db branches --json");
211
251
  }
212
252
  const res = await api.del(`/v1/branches/${branch.id}`, token, query);
213
- s.stop();
253
+ s?.stop();
214
254
  if (!res.ok) {
215
- error("Failed to delete branch.");
216
- process.exit(1);
255
+ emitError(res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE" : "CLI_ERROR", apiError(res, "Failed to delete branch."), "");
256
+ }
257
+ if (jsonMode) {
258
+ json({ branch: { id: branch.id, name, project_id: projectId }, deleted: true });
259
+ return;
217
260
  }
218
261
  log();
219
262
  log(` ${colors.green(">")} Branch ${colors.cyan(name)} deleted`);
@@ -231,60 +274,194 @@ export async function studio() {
231
274
  log();
232
275
  openBrowser(studioUrl);
233
276
  }
234
- export async function query(sql) {
277
+ /**
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.
282
+ */
283
+ function parseBranchFlag(args) {
284
+ let branchId;
285
+ const rest = [];
286
+ for (let i = 0; i < args.length; i++) {
287
+ const arg = args[i];
288
+ if (arg === "--branch") {
289
+ branchId = args[++i];
290
+ }
291
+ else if (arg.startsWith("--branch=")) {
292
+ branchId = arg.slice("--branch=".length);
293
+ }
294
+ else {
295
+ rest.push(arg);
296
+ }
297
+ }
298
+ return { branchId, rest };
299
+ }
300
+ export async function query(args = []) {
235
301
  const jsonMode = isJsonMode();
302
+ const { branchId, rest } = parseBranchFlag(args);
303
+ const sql = rest.join(" ").trim();
236
304
  if (!sql) {
237
- const msg = 'SQL query is required. Usage: bata db query "SELECT 1"';
238
- if (jsonMode)
239
- json({ error: msg });
240
- else
241
- error(msg);
242
- process.exit(1);
305
+ emitError("MISSING_ARG", "SQL query is required.", 'Usage: bata db query "SELECT 1"');
243
306
  }
244
307
  const token = requireToken();
245
308
  const config = loadConfig();
246
309
  const projectId = config.defaultProject;
247
310
  if (!projectId) {
248
- const msg = "No default project set.";
249
- if (jsonMode)
250
- json({ error: msg });
251
- else
252
- error(msg);
253
- process.exit(1);
311
+ emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
254
312
  }
255
313
  const s = jsonMode ? null : spinner("Running query");
256
- const res = await api.post(`/v1/query/${projectId}`, { sql }, token);
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_) {
319
+ const branch = await getPrimaryBranch(projectId, token, config.defaultTeam);
320
+ if (!branch) {
321
+ s?.stop();
322
+ emitError("BRANCH_NOT_FOUND", "No branch found for this project to run the query against.", "Check the project with: bata db branches --json");
323
+ }
324
+ branchId_ = branch.id;
325
+ }
326
+ const res = await api.post("/v1/sql/execute", { branch_id: branchId_, query: sql }, token);
257
327
  s?.stop();
258
328
  if (!res.ok) {
259
- if (jsonMode)
260
- json({ error: apiError(res, "Query failed") });
261
- else
262
- error(apiError(res, "Query failed"));
263
- process.exit(1);
329
+ // Cold-start / upstream blips are retryable (exit 6) so an agent backs off
330
+ // and retries instead of treating it as a hard CLI bug. The backend returns
331
+ // 503 + { code: "COMPUTE_STARTING" } while a just-created/idle branch's
332
+ // compute is still waking; connection-refused transients land here too.
333
+ const body = res.data;
334
+ if (isRetryable({ status: res.status, code: body?.code, message: body?.error })) {
335
+ emitError("COMPUTE_STARTING", apiError(res, "Compute is starting"), "compute is starting; retry in a few seconds");
336
+ }
337
+ const code = res.status === 401 || res.status === 403 ? "INVALID_KEY" : "CLI_ERROR";
338
+ emitError(code, apiError(res, "Query failed"), "");
339
+ }
340
+ // The endpoint returns 200 even when the SQL itself errored (it executed and
341
+ // failed). Treat that as a real error, not a silent success.
342
+ if (res.data.error) {
343
+ emitError("CLI_ERROR", res.data.error, "");
264
344
  }
265
- const { columns, rows, rowCount } = res.data;
345
+ const columns = res.data.columns ?? [];
346
+ const rows = res.data.rows ?? [];
347
+ const rowCount = res.data.rowCount ?? rows.length;
266
348
  if (jsonMode) {
267
349
  json({
268
- columns: columns ?? [],
269
- rows: rows ?? [],
270
- row_count: rowCount ?? rows?.length ?? 0,
350
+ columns,
351
+ rows, // column-keyed objects, exactly as the server returned them
352
+ row_count: rowCount,
353
+ command: res.data.command ?? null,
271
354
  });
272
355
  return;
273
356
  }
274
- if (columns && rows) {
357
+ if (columns.length && rows.length) {
358
+ log();
359
+ table(columns.map((c) => c.toUpperCase()), rows.map((r) => columns.map((col) => fmtCell(r[col]))));
360
+ log();
361
+ log(` ${colors.dim(`${rowCount} row(s)`)}`);
362
+ }
363
+ else if (rows.length) {
364
+ // Rows with no field metadata (rare) — render whatever keys came back.
365
+ const keys = Object.keys(rows[0]);
275
366
  log();
276
- table(columns.map((c) => c.toUpperCase()), rows.map((r) => r.map(String)));
367
+ table(keys.map((k) => k.toUpperCase()), rows.map((r) => keys.map((k) => fmtCell(r[k]))));
277
368
  log();
278
- log(` ${colors.dim(`${rowCount ?? rows.length} row(s)`)}`);
369
+ log(` ${colors.dim(`${rowCount} row(s)`)}`);
279
370
  }
280
371
  else {
281
372
  log();
282
- log(` ${colors.dim("Query executed successfully.")}`);
373
+ const cmd = res.data.command ? `${res.data.command} ` : "";
374
+ log(` ${colors.dim(`${cmd}OK — ${rowCount} row(s) affected.`)}`);
375
+ }
376
+ log();
377
+ }
378
+ /** Render a column-keyed value for the table (null → empty, objects → JSON). */
379
+ function fmtCell(v) {
380
+ if (v === null || v === undefined)
381
+ return "";
382
+ if (typeof v === "object")
383
+ return JSON.stringify(v);
384
+ return String(v);
385
+ }
386
+ /** Was a `--help` / `-h` flag passed anywhere in the args? */
387
+ function hasHelpFlag(args) {
388
+ return args.some((a) => a === "--help" || a === "-h");
389
+ }
390
+ /**
391
+ * Print usage for a `db` subcommand (or general `db` help if none/unknown) and
392
+ * return. Pure stdout — NEVER touches the network. `db query --help` must print
393
+ * this, not run the SQL "--help".
394
+ */
395
+ function dbHelp(sub) {
396
+ const usage = (line) => log(` ${colors.cyan(line)}`);
397
+ const note = (line) => log(` ${colors.dim(line)}`);
398
+ log();
399
+ switch (sub) {
400
+ case "query":
401
+ log(` ${colors.bold("bata db query")} — run a SQL query against a branch`);
402
+ log();
403
+ usage('bata db query "SELECT 1"');
404
+ usage("bata db query <sql> [--branch <branch_id>] [--json]");
405
+ log();
406
+ note("--branch <branch_id> Target a specific branch (default: the project's primary)");
407
+ note("--json Emit rows as JSON objects + row_count");
408
+ note("Cold-start note: a just-created/idle branch may answer with exit 6");
409
+ note("(retryable) while its compute wakes — retry in a few seconds.");
410
+ break;
411
+ case "branches":
412
+ log(` ${colors.bold("bata db branches")} — list branches and their compute status`);
413
+ log();
414
+ usage("bata db branches [--json]");
415
+ log();
416
+ note("STATUS shows the live compute state; a ✓ means ready to query.");
417
+ note("--json includes computeStatus + ready — poll these after create/cold start.");
418
+ break;
419
+ case "branch":
420
+ log(` ${colors.bold("bata db branch")} — create or delete a branch`);
421
+ log();
422
+ usage("bata db branch create <name>");
423
+ usage("bata db branch delete <name> [--yes]");
424
+ break;
425
+ case "url":
426
+ log(` ${colors.bold("bata db url")} — print the connection string`);
427
+ log();
428
+ usage("bata db url [--json]");
429
+ break;
430
+ case "connect":
431
+ log(` ${colors.bold("bata db connect")} — open an interactive psql session`);
432
+ log();
433
+ usage("bata db connect");
434
+ note("Interactive only — use `bata db query` / `bata db url` headlessly.");
435
+ break;
436
+ case "studio":
437
+ log(` ${colors.bold("bata db studio")} — open the table browser in your browser`);
438
+ log();
439
+ usage("bata db studio");
440
+ break;
441
+ default:
442
+ log(` ${colors.bold("bata db")} — database commands`);
443
+ log();
444
+ usage("bata db connect Open psql to your database");
445
+ usage("bata db url Print connection string");
446
+ usage("bata db branches List branches + compute status");
447
+ usage("bata db branch create Create a new branch");
448
+ usage("bata db branch delete Delete a branch");
449
+ usage("bata db studio Open the table browser");
450
+ usage("bata db query <sql> Run a SQL query (--branch <id> to target a branch)");
451
+ log();
452
+ note("Add --help to any subcommand for details (e.g. bata db query --help).");
283
453
  }
284
454
  log();
285
455
  }
286
456
  export async function handleDb(args) {
287
457
  const sub = args[0];
458
+ // A --help / -h anywhere prints usage for the subcommand and exits 0 WITHOUT
459
+ // any network call. This must run before dispatch so `db query --help` never
460
+ // treats "--help" as SQL and hits /v1/sql/execute.
461
+ if (hasHelpFlag(args)) {
462
+ dbHelp(sub);
463
+ return;
464
+ }
288
465
  switch (sub) {
289
466
  case "connect":
290
467
  return connect();
@@ -298,18 +475,13 @@ export async function handleDb(args) {
298
475
  return branchCreate(args[2]);
299
476
  if (action === "delete")
300
477
  return branchDelete(args[2]);
301
- error(`Unknown: db branch ${action || ""}`);
302
- log(` ${colors.dim("Available:")} create, delete`);
303
- process.exit(1);
304
- break;
478
+ emitError("INVALID_FLAG", `Unknown: db branch ${action || ""}`, "Available: create, delete");
305
479
  }
306
480
  case "studio":
307
481
  return studio();
308
482
  case "query":
309
- return query(args.slice(1).join(" "));
483
+ return query(args.slice(1));
310
484
  default:
311
- error(`Unknown subcommand: db ${sub || ""}`);
312
- log(` ${colors.dim("Available:")} connect, url, branches, branch, studio, query`);
313
- process.exit(1);
485
+ emitError("INVALID_FLAG", `Unknown subcommand: db ${sub || ""}`, "Available: connect, url, branches, branch, studio, query");
314
486
  }
315
487
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,173 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ const { get, post } = vi.hoisted(() => ({
6
+ get: vi.fn(async () => ({
7
+ ok: true,
8
+ status: 200,
9
+ data: { id: "proj_1", name: "demo", branches: [{ id: "br_primary", name: "main", is_primary: true, status: "ready" }] },
10
+ })),
11
+ post: vi.fn(async () => ({ ok: true, status: 200, data: { columns: ["n"], rows: [{ n: "1" }], rowCount: 1 } })),
12
+ }));
13
+ vi.mock("../api.js", () => ({
14
+ api: { get, post, del: vi.fn() },
15
+ resolveTeamId: vi.fn(async () => "team_test"),
16
+ // Mirror the real apiError so retryable mapping (which reads the server code)
17
+ // gets a realistic message; we don't need its exact format here.
18
+ apiError: (res, fallback) => {
19
+ const msg = res?.data?.error ?? fallback;
20
+ const code = res?.data?.code ? ` (${res.data.code})` : "";
21
+ return `${msg}${code}`;
22
+ },
23
+ asList: (data) => (Array.isArray(data) ? data : []),
24
+ }));
25
+ import { query, handleDb } from "./db.js";
26
+ import { setRuntime } from "../config.js";
27
+ let tmpHome;
28
+ let logSpy;
29
+ // process.exit must NOT actually exit the test runner. Throw a tagged error so
30
+ // the handler unwinds at the exit point; tests catch it and read the code.
31
+ class ExitError extends Error {
32
+ code;
33
+ constructor(code) {
34
+ super(`exit ${code}`);
35
+ this.code = code;
36
+ }
37
+ }
38
+ beforeEach(() => {
39
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "bata-cli-db-test-"));
40
+ vi.stubEnv("HOME", tmpHome);
41
+ vi.stubEnv("USERPROFILE", tmpHome);
42
+ // A real credential + a default project so query() reaches the network seam.
43
+ fs.writeFileSync(path.join(tmpHome, ".batarc"), JSON.stringify({ defaultProject: "proj_1" }));
44
+ setRuntime({ apiKey: "test-token", apiUrl: "https://api.test.local", json: true, yes: false });
45
+ get.mockClear();
46
+ post.mockClear();
47
+ vi.spyOn(process, "exit").mockImplementation(((code) => {
48
+ throw new ExitError(code ?? 0);
49
+ }));
50
+ // Silence the JSON/decorative output so test logs stay clean.
51
+ logSpy = vi.spyOn(console, "log").mockImplementation(() => { });
52
+ vi.spyOn(console, "error").mockImplementation(() => { });
53
+ });
54
+ afterEach(() => {
55
+ vi.unstubAllEnvs();
56
+ vi.restoreAllMocks();
57
+ setRuntime({ json: false, yes: false });
58
+ fs.rmSync(tmpHome, { recursive: true, force: true });
59
+ });
60
+ /** Run a handler that's expected to call process.exit; return the exit code. */
61
+ async function exitCodeOf(run) {
62
+ try {
63
+ await run();
64
+ }
65
+ catch (e) {
66
+ if (e instanceof ExitError)
67
+ return e.code;
68
+ throw e;
69
+ }
70
+ return 0; // handler returned without exiting (success path)
71
+ }
72
+ describe("db query — --help short-circuits before any network call", () => {
73
+ it("`db query --help` prints help and makes NO api call (exit 0)", async () => {
74
+ const code = await exitCodeOf(() => handleDb(["query", "--help"]));
75
+ expect(post).not.toHaveBeenCalled();
76
+ expect(get).not.toHaveBeenCalled();
77
+ expect(code).toBe(0);
78
+ // It actually printed usage, not a row result.
79
+ const printed = logSpy.mock.calls.map((c) => String(c[0])).join("\n");
80
+ expect(printed).toContain("bata db query");
81
+ expect(printed).toContain("--branch");
82
+ });
83
+ it("`db query -h` (short flag) also short-circuits with no api call", async () => {
84
+ const code = await exitCodeOf(() => handleDb(["query", "-h"]));
85
+ expect(post).not.toHaveBeenCalled();
86
+ expect(get).not.toHaveBeenCalled();
87
+ expect(code).toBe(0);
88
+ });
89
+ it("`db --help` prints general db help with no api call", async () => {
90
+ const code = await exitCodeOf(() => handleDb(["--help"]));
91
+ expect(post).not.toHaveBeenCalled();
92
+ expect(get).not.toHaveBeenCalled();
93
+ expect(code).toBe(0);
94
+ });
95
+ });
96
+ 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"]));
99
+ 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();
103
+ });
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");
107
+ });
108
+ it("without --branch, resolves the project's primary branch (default behavior)", async () => {
109
+ await exitCodeOf(() => query(["SELECT 1"]));
110
+ expect(get).toHaveBeenCalledTimes(1); // resolved primary
111
+ expect(post).toHaveBeenCalledWith("/v1/sql/execute", expect.objectContaining({ branch_id: "br_primary", query: "SELECT 1" }), "test-token");
112
+ });
113
+ });
114
+ describe("db query — retryable cold-start mapping (exit 6)", () => {
115
+ it("503 + { code: COMPUTE_STARTING } maps to exit 6 (retryable), not 1", async () => {
116
+ post.mockResolvedValueOnce({
117
+ ok: false,
118
+ status: 503,
119
+ data: { error: "compute is starting", code: "COMPUTE_STARTING" },
120
+ });
121
+ const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "br_cold"]));
122
+ expect(code).toBe(6);
123
+ });
124
+ it("REGRESSION GUARD: a non-5xx status carrying { code: COMPUTE_STARTING } still maps to exit 6", async () => {
125
+ // The pre-fix code only looked at the HTTP status (>=500 || 0), so a
126
+ // COMPUTE_STARTING surfaced on, say, a 425 would have been CLI_ERROR (exit
127
+ // 1). This is the exact guard for the body-code inspection.
128
+ post.mockResolvedValueOnce({
129
+ ok: false,
130
+ status: 425,
131
+ data: { error: "compute is starting", code: "COMPUTE_STARTING" },
132
+ });
133
+ const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "br_cold"]));
134
+ expect(code).toBe(6);
135
+ });
136
+ it("a connection-refused error body maps to exit 6", async () => {
137
+ post.mockResolvedValueOnce({
138
+ ok: false,
139
+ status: 0,
140
+ data: { error: "connect ECONNREFUSED 127.0.0.1:5432" },
141
+ });
142
+ const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "br_cold"]));
143
+ expect(code).toBe(6);
144
+ });
145
+ it("a generic 5xx maps to exit 6", async () => {
146
+ post.mockResolvedValueOnce({ ok: false, status: 502, data: { error: "bad gateway" } });
147
+ const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "br_cold"]));
148
+ expect(code).toBe(6);
149
+ });
150
+ it("a genuine SQL syntax error (200 with error field) stays exit 1, NOT retryable", async () => {
151
+ post.mockResolvedValueOnce({
152
+ ok: true,
153
+ status: 200,
154
+ data: { error: 'syntax error at or near "SELCT"' },
155
+ });
156
+ const code = await exitCodeOf(() => query(["SELCT 1", "--branch", "br_ok"]));
157
+ expect(code).toBe(1);
158
+ });
159
+ it("a 400 bad-input SQL error stays exit 1, NOT retryable", async () => {
160
+ post.mockResolvedValueOnce({
161
+ ok: false,
162
+ status: 400,
163
+ data: { error: "relation does not exist" },
164
+ });
165
+ const code = await exitCodeOf(() => query(["SELECT * FROM nope", "--branch", "br_ok"]));
166
+ expect(code).toBe(1);
167
+ });
168
+ it("a 401 stays auth (exit 4), not retryable", async () => {
169
+ post.mockResolvedValueOnce({ ok: false, status: 401, data: { error: "invalid key" } });
170
+ const code = await exitCodeOf(() => query(["SELECT 1", "--branch", "br_ok"]));
171
+ expect(code).toBe(4);
172
+ });
173
+ });