@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.
- package/README.md +75 -10
- package/dist/api.js +1 -1
- package/dist/args.js +1 -1
- package/dist/commands/api-keys.js +20 -37
- package/dist/commands/connect.js +12 -8
- package/dist/commands/db.d.ts +1 -1
- package/dist/commands/db.js +256 -84
- package/dist/commands/db.test.d.ts +1 -0
- package/dist/commands/db.test.js +173 -0
- package/dist/commands/migrate.js +20 -20
- package/dist/commands/projects.js +39 -37
- package/dist/commands/projects.test.d.ts +1 -0
- package/dist/commands/projects.test.js +104 -0
- package/dist/commands/schema.js +184 -19
- package/dist/commands/status.js +16 -13
- package/dist/commands/usage.d.ts +13 -0
- package/dist/commands/usage.js +164 -0
- package/dist/config.d.ts +3 -0
- package/dist/config.js +9 -2
- package/dist/index.js +52 -13
- package/dist/utils/errors.d.ts +47 -0
- package/dist/utils/errors.js +91 -0
- package/dist/utils/logger.js +1 -1
- package/dist/utils/prompts.d.ts +10 -0
- package/dist/utils/prompts.js +15 -0
- package/package.json +5 -4
package/dist/commands/db.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
process.exit(1);
|
|
179
|
+
emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
|
|
155
180
|
}
|
|
156
|
-
|
|
181
|
+
// Don't block on an interactive prompt headlessly.
|
|
182
|
+
let branchName = name;
|
|
157
183
|
if (!branchName) {
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
200
|
+
s?.stop();
|
|
170
201
|
if (!res.ok) {
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
188
|
-
process.exit(1);
|
|
228
|
+
emitError("MISSING_ARG", "Branch name is required.", "Usage: bata db branch delete <name>");
|
|
189
229
|
}
|
|
190
|
-
|
|
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
|
|
203
|
-
|
|
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
|
|
209
|
-
|
|
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
|
|
253
|
+
s?.stop();
|
|
214
254
|
if (!res.ok) {
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
|
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
|
|
269
|
-
rows
|
|
270
|
-
row_count: rowCount
|
|
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(
|
|
367
|
+
table(keys.map((k) => k.toUpperCase()), rows.map((r) => keys.map((k) => fmtCell(r[k]))));
|
|
277
368
|
log();
|
|
278
|
-
log(` ${colors.dim(`${rowCount
|
|
369
|
+
log(` ${colors.dim(`${rowCount} row(s)`)}`);
|
|
279
370
|
}
|
|
280
371
|
else {
|
|
281
372
|
log();
|
|
282
|
-
|
|
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
|
-
|
|
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)
|
|
483
|
+
return query(args.slice(1));
|
|
310
484
|
default:
|
|
311
|
-
|
|
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
|
+
});
|