@batadata/cli 0.1.7 → 0.1.9

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 CHANGED
@@ -43,6 +43,45 @@ bata connect my-app # open psql (auto-wakes the compute if suspended)
43
43
  bata usage # per-dimension cost for the current billing period
44
44
  ```
45
45
 
46
+ ## Project linking
47
+
48
+ Tired of threading `--project <id>` through every command? Link a directory to a
49
+ project once — Vercel/Neon-style — and every command run inside it (or any
50
+ subdirectory) targets that project automatically.
51
+
52
+ ```bash
53
+ bata link my-app # resolves by name or id, writes .batadata/project.json
54
+ bata link # no argument, in a TTY: pick from a list
55
+ bata link --status # show what this directory resolves to
56
+ bata db query "SELECT 1" # no --project needed anymore
57
+ bata unlink # remove the link
58
+ ```
59
+
60
+ `bata link` writes `.batadata/project.json` in the current directory:
61
+
62
+ ```json
63
+ { "projectId": "proj_abc123", "branchId": null }
64
+ ```
65
+
66
+ **Add `.batadata/` to your `.gitignore`** — the link is per-checkout, not shared.
67
+
68
+ ### Pin a branch
69
+
70
+ ```bash
71
+ bata db branch checkout preview # writes the branch id into .batadata/project.json
72
+ bata db query "SELECT 1" # now runs against `preview` — no --branch flag
73
+ ```
74
+
75
+ ### Resolution precedence
76
+
77
+ Everywhere a project or branch is resolved, the same rule applies — **an explicit
78
+ flag always wins**, so linking never silently overrides an intentional request:
79
+
80
+ 1. an explicit `--project` / `--branch` flag (or a positional id)
81
+ 2. `.batadata/project.json`, discovered by walking **up** from the current
82
+ directory like git (stops at `$HOME` or the filesystem root)
83
+ 3. `defaultProject` in `~/.batarc` (the per-machine fallback)
84
+
46
85
  ## Commands
47
86
 
48
87
  | Command | Description |
@@ -51,6 +90,8 @@ bata usage # per-dimension cost for the current billing period
51
90
  | `connect <name>` | Open `psql` to a project (auto-wakes if suspended) |
52
91
  | `status` | Show all projects and their status |
53
92
  | `usage` | Show per-dimension cost (compute, storage, transfer) for the current period |
93
+ | `link [project]` | Link the current directory to a project (see [Project linking](#project-linking)) |
94
+ | `unlink` | Remove the current directory's project link |
54
95
  | `login` / `logout` / `whoami` | Manage your session |
55
96
  | `api-keys` | Create / list / revoke API keys |
56
97
  | `projects` | `list`, `create`, `info`, `delete` |
@@ -59,7 +100,10 @@ bata usage # per-dimension cost for the current billing period
59
100
  | `db query <sql>` | Run a SQL query and print the rows |
60
101
  | `db branches` | List database branches |
61
102
  | `db branch create` / `db branch delete` | Manage branches |
103
+ | `db branch checkout <name-or-id>` | Pin a branch into the directory's link |
62
104
  | `db studio` | Open the table browser in your browser |
105
+ | `restore points` | List recovery points and the PITR window (see [Point-in-time restore](#point-in-time-restore-pitr)) |
106
+ | `restore create` | Restore a branch to a timestamp/LSN as a **new** branch |
63
107
  | `schema check <file>` | Check a proposed schema change against live query traffic |
64
108
  | `generate` | Generate types from your database schema (`--watch` for watch mode) |
65
109
  | `dev` | Print the local development setup guide |
@@ -71,6 +115,38 @@ safety.
71
115
 
72
116
  Run `bata --help` for the full, authoritative command list, or `bata --version`.
73
117
 
118
+ ## Point-in-time restore (PITR)
119
+
120
+ Recover a branch to an earlier point in time. Restore is **non-destructive**: it
121
+ creates a **new** branch at the chosen point and never overwrites the source
122
+ branch's data.
123
+
124
+ ```bash
125
+ bata restore points # what can I restore to?
126
+ bata restore create \
127
+ --branch main \
128
+ --at 2026-07-04T12:00:00Z \
129
+ --name before-the-bad-migration # → a new branch at that timestamp
130
+ ```
131
+
132
+ `--at` accepts either an **ISO-8601 timestamp** (e.g. `2026-07-04T12:00:00Z`) or
133
+ a Postgres **LSN** (e.g. `0/15994B0`); the CLI detects which you passed. If
134
+ `--name` is omitted the new branch is named `restore-<branch>-<timestamp>`. The
135
+ new branch's compute may still be provisioning when the command returns — poll
136
+ `bata db branches` before you query it.
137
+
138
+ ### How far back can I restore? (honest window)
139
+
140
+ Recovery points are **timeline-metadata snapshots** the control plane's backup
141
+ scheduler records about **every 6 hours** (`BACKUP_INTERVAL_HOURS`, default `6`).
142
+ They are *not* full physical backups, and the earliest point `restore points`
143
+ lists is simply the oldest metadata row we have — **not** a guaranteed floor on
144
+ how far back you can restore. Actual restorability to an arbitrary timestamp
145
+ depends on how much WAL the storage engine still retains: if the WAL no longer
146
+ covers your timestamp, the server returns *"No data available at the requested
147
+ timestamp"* and the restore is refused. We surface that verbatim rather than
148
+ implying the window is deeper than it is.
149
+
74
150
  ## Headless / agent use
75
151
 
76
152
  Every command runs headlessly with just `BATA_API_KEY` set — no `bata login`,
@@ -125,6 +201,11 @@ bata projects delete --yes --json
125
201
  exists but its compute may still be provisioning. Poll `bata db branches --json`
126
202
  until the branch reports a ready status before connecting to it.
127
203
 
204
+ To avoid passing `--project` on every call, an agent can `bata link <id> --json`
205
+ once (it emits `{ "linked": true, "project_id", "project_name", "branch_id",
206
+ "link_file" }`) and drop the flag from every subsequent command in that
207
+ directory. `bata link --status --json` reports the current link.
208
+
128
209
  ### Cost truth
129
210
 
130
211
  `bata usage` reports cost per dimension and is deliberately honest about what is
package/dist/api.d.ts CHANGED
@@ -50,5 +50,6 @@ export declare function asList<T = unknown>(data: unknown): T[];
50
50
  export declare const api: {
51
51
  get: <T = unknown>(path: string, token?: string, query?: Record<string, string>) => Promise<ApiResponse<T>>;
52
52
  post: <T = unknown>(path: string, body: Record<string, unknown>, token?: string) => Promise<ApiResponse<T>>;
53
+ patch: <T = unknown>(path: string, body: Record<string, unknown>, token?: string, query?: Record<string, string>) => Promise<ApiResponse<T>>;
53
54
  del: <T = unknown>(path: string, token?: string, query?: Record<string, string>) => Promise<ApiResponse<T>>;
54
55
  };
package/dist/api.js CHANGED
@@ -152,5 +152,6 @@ export function asList(data) {
152
152
  export const api = {
153
153
  get: (path, token, query) => request("GET", path, { token, query }),
154
154
  post: (path, body, token) => request("POST", path, { token, body }),
155
+ patch: (path, body, token, query) => request("PATCH", path, { token, body, query }),
155
156
  del: (path, token, query) => request("DELETE", path, { token, query }),
156
157
  };
@@ -9,6 +9,7 @@ import { api } from "../api.js";
9
9
  import { requireToken, loadConfig, isJsonMode } from "../config.js";
10
10
  import { colors, log, error, spinner, info as logInfo } from "../utils/logger.js";
11
11
  import { emitError } from "../utils/errors.js";
12
+ import { resolveProjectId } from "../link.js";
12
13
  export async function connect(args) {
13
14
  // psql is an interactive session — there's no headless equivalent. Don't spawn
14
15
  // it in --json or non-TTY contexts; point agents at the headless surfaces.
@@ -17,8 +18,9 @@ export async function connect(args) {
17
18
  }
18
19
  const token = requireToken();
19
20
  const config = loadConfig();
20
- // Accept project name as argument, or use default
21
- let projectId = config.defaultProject;
21
+ // Accept project name as argument, or use the resolved default
22
+ // (--project isn't a connect flag; precedence is link > config here).
23
+ let projectId = resolveProjectId().projectId;
22
24
  const projectName = args[0];
23
25
  if (projectName && !projectName.startsWith("-")) {
24
26
  // Resolve project name to ID
@@ -1,8 +1,42 @@
1
+ /**
2
+ * Parse a relative duration like `30m`, `2h`, `7d` (units: s/m/h/d/w) into
3
+ * milliseconds. Returns `{ error }` for anything malformed or non-positive so
4
+ * the caller can surface a clean CLI error instead of minting a bad TTL.
5
+ * Exported for unit testing.
6
+ */
7
+ export declare function parseDuration(input: string): {
8
+ ms?: number;
9
+ error?: string;
10
+ };
11
+ /**
12
+ * Split `db branch create` args into the positional name plus the
13
+ * `--expires-in` / `--purpose` flags (both `--flag value` and `--flag=value`
14
+ * forms). Keeps the command order-independent, like `db query`'s `--branch`.
15
+ */
16
+ export declare function parseBranchCreateArgs(args: string[]): {
17
+ name?: string;
18
+ expiresIn?: string;
19
+ purpose?: string;
20
+ };
1
21
  export declare function connect(): Promise<void>;
2
22
  export declare function url(): Promise<void>;
3
23
  export declare function branches(): Promise<void>;
4
- export declare function branchCreate(name?: string): Promise<void>;
24
+ export declare function branchCreate(args?: string[]): Promise<void>;
5
25
  export declare function branchDelete(name?: string): Promise<void>;
26
+ /**
27
+ * `bata db branch protect <name-or-id>` / `unprotect <name-or-id>` — flip a
28
+ * branch's protection flag (PATCH /v1/branches/:id). A protected branch refuses
29
+ * delete/reset/rollback/reap — the safety rail for headless/agent ops. The ref
30
+ * is resolved by name OR id, like the other branch commands.
31
+ */
32
+ export declare function branchSetProtected(ref: string | undefined, wantProtected: boolean): Promise<void>;
33
+ /**
34
+ * `bata db branch checkout <name-or-id>` — pin a branch into the directory's
35
+ * `.batadata/project.json` so later `db query` / `db url` target it with no
36
+ * `--branch` flag. Requires a linked project (or an explicit `--project`); the
37
+ * branch ref is resolved by name OR id, just like `db query --branch`.
38
+ */
39
+ export declare function branchCheckout(args: string[]): Promise<void>;
6
40
  export declare function studio(): Promise<void>;
7
41
  export declare function query(args?: string[]): Promise<void>;
8
42
  export declare function handleDb(args: string[]): Promise<void>;
@@ -1,10 +1,12 @@
1
1
  import { execSync, spawn } from "node:child_process";
2
+ import * as path from "node:path";
2
3
  import { api, apiError } from "../api.js";
3
4
  import { requireToken, loadConfig, isJsonMode } from "../config.js";
4
- import { colors, log, json, error, spinner, table, heading, info as logInfo } from "../utils/logger.js";
5
+ import { colors, log, json, error, success, spinner, table, heading, info as logInfo } from "../utils/logger.js";
5
6
  import { prompt, confirmDestructive } from "../utils/prompts.js";
6
7
  import { openBrowser } from "../utils/open.js";
7
8
  import { emitError, isRetryable } from "../utils/errors.js";
9
+ import { resolveProjectId, resolveBranchId, findLinkFile, writeLinkFile } from "../link.js";
8
10
  async function getConnectionInfo(projectId, token) {
9
11
  // reveal=true so the returned string is actually usable (the owner is asking).
10
12
  const res = await api.get(`/v1/connection-info/${projectId}`, token, { reveal: "true" });
@@ -30,6 +32,59 @@ function formatDate(iso) {
30
32
  const d = new Date(iso);
31
33
  return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
32
34
  }
35
+ const DURATION_UNIT_MS = {
36
+ s: 1_000,
37
+ m: 60_000,
38
+ h: 3_600_000,
39
+ d: 86_400_000,
40
+ w: 604_800_000,
41
+ };
42
+ /**
43
+ * Parse a relative duration like `30m`, `2h`, `7d` (units: s/m/h/d/w) into
44
+ * milliseconds. Returns `{ error }` for anything malformed or non-positive so
45
+ * the caller can surface a clean CLI error instead of minting a bad TTL.
46
+ * Exported for unit testing.
47
+ */
48
+ export function parseDuration(input) {
49
+ const match = /^(\d+)(s|m|h|d|w)$/.exec(input.trim());
50
+ if (!match) {
51
+ return { error: `Invalid duration "${input}". Use forms like 30m, 2h, 7d (units: s/m/h/d/w).` };
52
+ }
53
+ const n = parseInt(match[1], 10);
54
+ if (n <= 0) {
55
+ return { error: `Duration "${input}" must be greater than zero.` };
56
+ }
57
+ return { ms: n * DURATION_UNIT_MS[match[2]] };
58
+ }
59
+ /**
60
+ * Split `db branch create` args into the positional name plus the
61
+ * `--expires-in` / `--purpose` flags (both `--flag value` and `--flag=value`
62
+ * forms). Keeps the command order-independent, like `db query`'s `--branch`.
63
+ */
64
+ export function parseBranchCreateArgs(args) {
65
+ let name;
66
+ let expiresIn;
67
+ let purpose;
68
+ for (let i = 0; i < args.length; i++) {
69
+ const arg = args[i];
70
+ if (arg === "--expires-in") {
71
+ expiresIn = args[++i];
72
+ }
73
+ else if (arg.startsWith("--expires-in=")) {
74
+ expiresIn = arg.slice("--expires-in=".length);
75
+ }
76
+ else if (arg === "--purpose") {
77
+ purpose = args[++i];
78
+ }
79
+ else if (arg.startsWith("--purpose=")) {
80
+ purpose = arg.slice("--purpose=".length);
81
+ }
82
+ else if (!arg.startsWith("--") && name === undefined) {
83
+ name = arg;
84
+ }
85
+ }
86
+ return { name, expiresIn, purpose };
87
+ }
33
88
  /**
34
89
  * Human-readable STATUS cell for a branch. Prefers the live compute lifecycle
35
90
  * (computeStatus) over the static branch row status, and appends a green check
@@ -85,7 +140,7 @@ export async function connect() {
85
140
  }
86
141
  const token = requireToken();
87
142
  const config = loadConfig();
88
- const projectId = config.defaultProject;
143
+ const projectId = resolveProjectId().projectId;
89
144
  if (!projectId) {
90
145
  emitError("NO_PROJECT", "No default project.", "Run bata projects create or set one with bata projects info <id>.");
91
146
  }
@@ -119,7 +174,7 @@ export async function connect() {
119
174
  export async function url() {
120
175
  const token = requireToken();
121
176
  const config = loadConfig();
122
- const projectId = config.defaultProject;
177
+ const projectId = resolveProjectId().projectId;
123
178
  if (!projectId) {
124
179
  emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
125
180
  }
@@ -138,7 +193,7 @@ export async function branches() {
138
193
  const token = requireToken();
139
194
  const config = loadConfig();
140
195
  const jsonMode = isJsonMode();
141
- const projectId = config.defaultProject;
196
+ const projectId = resolveProjectId().projectId;
142
197
  if (!projectId) {
143
198
  emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
144
199
  }
@@ -161,6 +216,11 @@ export async function branches() {
161
216
  // and a successful query.
162
217
  computeStatus: b.computeStatus ?? null,
163
218
  ready: b.ready ?? null,
219
+ // Ephemeral-branch TTL: when set, the branch is auto-reaped after this.
220
+ expires_at: b.expiresAt ?? null,
221
+ purpose: b.purpose ?? null,
222
+ // Protection flag — a protected branch refuses delete/reset/rollback/reap.
223
+ protected: b.isProtected ?? false,
164
224
  created_at: b.createdAt ?? null,
165
225
  })),
166
226
  count: branchList.length,
@@ -173,24 +233,40 @@ export async function branches() {
173
233
  log();
174
234
  return;
175
235
  }
176
- table(["NAME", "PRIMARY", "STATUS", "CREATED"], branchList.map((b) => [
236
+ table(["NAME", "PRIMARY", "PROTECTED", "STATUS", "EXPIRES", "CREATED"], branchList.map((b) => [
177
237
  b.name,
178
238
  b.isPrimary ? colors.green("yes") : "-",
239
+ // Protected branches are locked against destructive ops — flag them so the
240
+ // column is scannable.
241
+ b.isProtected ? colors.yellow("locked") : "-",
179
242
  // Live compute lifecycle (computeStatus), with a check once ready so the
180
243
  // column is scannable.
181
244
  branchStatusLabel(b),
245
+ // TTL'd branches show their reap date; permanent branches show "-".
246
+ b.expiresAt ? formatDate(b.expiresAt) : "-",
182
247
  formatDate(b.createdAt ?? ""),
183
248
  ]));
184
249
  log();
185
250
  }
186
- export async function branchCreate(name) {
251
+ export async function branchCreate(args = []) {
187
252
  const jsonMode = isJsonMode();
188
253
  const token = requireToken();
189
254
  const config = loadConfig();
190
- const projectId = config.defaultProject;
255
+ const projectId = resolveProjectId().projectId;
191
256
  if (!projectId) {
192
257
  emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
193
258
  }
259
+ const { name, expiresIn, purpose } = parseBranchCreateArgs(args);
260
+ // A relative TTL (--expires-in 2h) becomes an absolute expires_at the server
261
+ // enforces. Parse it up front so a bad duration fails fast, before any call.
262
+ let expiresAt;
263
+ if (expiresIn !== undefined) {
264
+ const parsed = parseDuration(expiresIn);
265
+ if (parsed.error) {
266
+ emitError("INVALID_FLAG", parsed.error, "Usage: bata db branch create <name> --expires-in 2h");
267
+ }
268
+ expiresAt = new Date(Date.now() + parsed.ms).toISOString();
269
+ }
194
270
  // Don't block on an interactive prompt headlessly.
195
271
  let branchName = name;
196
272
  if (!branchName) {
@@ -209,6 +285,10 @@ export async function branchCreate(name) {
209
285
  };
210
286
  if (config.defaultTeam)
211
287
  body.team_id = config.defaultTeam;
288
+ if (expiresAt)
289
+ body.expires_at = expiresAt;
290
+ if (purpose)
291
+ body.purpose = purpose;
212
292
  const res = await api.post("/v1/branches", body, token);
213
293
  s?.stop();
214
294
  if (!res.ok) {
@@ -216,7 +296,13 @@ export async function branchCreate(name) {
216
296
  }
217
297
  if (jsonMode) {
218
298
  json({
219
- branch: { id: res.data.id, name: res.data.name ?? branchName, project_id: projectId },
299
+ branch: {
300
+ id: res.data.id,
301
+ name: res.data.name ?? branchName,
302
+ project_id: projectId,
303
+ expires_at: res.data.expiresAt ?? expiresAt ?? null,
304
+ purpose: res.data.purpose ?? purpose ?? null,
305
+ },
220
306
  // The branch row exists immediately, but its compute may still be
221
307
  // provisioning — poll `db branches` for status before connecting.
222
308
  ready: false,
@@ -226,6 +312,9 @@ export async function branchCreate(name) {
226
312
  }
227
313
  log();
228
314
  log(` ${colors.green(">")} Branch ${colors.cyan(branchName)} created`);
315
+ if (expiresAt) {
316
+ log(` ${colors.dim("Expires")} ${formatDate(expiresAt)} ${colors.dim("(auto-deleted)")}`);
317
+ }
229
318
  log(` ${colors.dim("Poll readiness with")} ${colors.cyan("bata db branches")}`);
230
319
  log();
231
320
  }
@@ -233,7 +322,7 @@ export async function branchDelete(name) {
233
322
  const jsonMode = isJsonMode();
234
323
  const token = requireToken();
235
324
  const config = loadConfig();
236
- const projectId = config.defaultProject;
325
+ const projectId = resolveProjectId().projectId;
237
326
  if (!projectId) {
238
327
  emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
239
328
  }
@@ -275,9 +364,123 @@ export async function branchDelete(name) {
275
364
  log(` ${colors.green(">")} Branch ${colors.cyan(name)} deleted`);
276
365
  log();
277
366
  }
278
- export async function studio() {
367
+ /**
368
+ * `bata db branch protect <name-or-id>` / `unprotect <name-or-id>` — flip a
369
+ * branch's protection flag (PATCH /v1/branches/:id). A protected branch refuses
370
+ * delete/reset/rollback/reap — the safety rail for headless/agent ops. The ref
371
+ * is resolved by name OR id, like the other branch commands.
372
+ */
373
+ export async function branchSetProtected(ref, wantProtected) {
374
+ const jsonMode = isJsonMode();
375
+ const token = requireToken();
279
376
  const config = loadConfig();
280
- const projectId = config.defaultProject;
377
+ const projectId = resolveProjectId().projectId;
378
+ const verb = wantProtected ? "protect" : "unprotect";
379
+ if (!projectId) {
380
+ emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
381
+ }
382
+ if (!ref) {
383
+ emitError("MISSING_ARG", "Branch name or id is required.", `Usage: bata db branch ${verb} <name-or-id>`);
384
+ }
385
+ const s = jsonMode ? null : spinner(`Resolving branch ${colors.cyan(ref)}`);
386
+ const branch = await resolveBranchRef(projectId, token, config.defaultTeam, ref);
387
+ s?.stop();
388
+ if (!branch) {
389
+ emitError("BRANCH_NOT_FOUND", `Branch "${ref}" not found in this project.`, "List branches with: bata db branches --json");
390
+ }
391
+ const s2 = jsonMode ? null : spinner(`${wantProtected ? "Protecting" : "Unprotecting"} branch ${colors.cyan(branch.name)}`);
392
+ const query = {};
393
+ if (config.defaultTeam)
394
+ query.team_id = config.defaultTeam;
395
+ const res = await api.patch(`/v1/branches/${branch.id}`, { protected: wantProtected }, token, query);
396
+ s2?.stop();
397
+ if (!res.ok) {
398
+ emitError(res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE" : "CLI_ERROR", apiError(res, `Failed to ${verb} branch.`), "");
399
+ }
400
+ if (jsonMode) {
401
+ json({
402
+ branch: { id: branch.id, name: branch.name, project_id: projectId },
403
+ protected: res.data.isProtected ?? wantProtected,
404
+ });
405
+ return;
406
+ }
407
+ log();
408
+ if (wantProtected) {
409
+ log(` ${colors.green(">")} Branch ${colors.cyan(branch.name)} is now ${colors.yellow("protected")}`);
410
+ log(` ${colors.dim("It can't be deleted, reset, rolled back, or auto-reaped until unprotected.")}`);
411
+ }
412
+ else {
413
+ log(` ${colors.green(">")} Branch ${colors.cyan(branch.name)} is no longer protected`);
414
+ }
415
+ log();
416
+ }
417
+ /**
418
+ * Pull a `--project <id>` / `--project=<id>` flag out of args, returning the
419
+ * explicit project id (if any) and the remaining positionals. Lets `db branch
420
+ * checkout` accept `--project` while the positional stays the branch ref.
421
+ */
422
+ function parseProjectFlag(args) {
423
+ let projectId;
424
+ const rest = [];
425
+ for (let i = 0; i < args.length; i++) {
426
+ const arg = args[i];
427
+ if (arg === "--project")
428
+ projectId = args[++i];
429
+ else if (arg.startsWith("--project="))
430
+ projectId = arg.slice("--project=".length);
431
+ else
432
+ rest.push(arg);
433
+ }
434
+ return { projectId, rest };
435
+ }
436
+ /**
437
+ * `bata db branch checkout <name-or-id>` — pin a branch into the directory's
438
+ * `.batadata/project.json` so later `db query` / `db url` target it with no
439
+ * `--branch` flag. Requires a linked project (or an explicit `--project`); the
440
+ * branch ref is resolved by name OR id, just like `db query --branch`.
441
+ */
442
+ export async function branchCheckout(args) {
443
+ const jsonMode = isJsonMode();
444
+ const { projectId: projectFlag, rest } = parseProjectFlag(args);
445
+ const ref = rest[0];
446
+ if (!ref) {
447
+ emitError("MISSING_ARG", "Branch name or id is required.", "Usage: bata db branch checkout <name-or-id>");
448
+ }
449
+ const { projectId } = resolveProjectId(projectFlag);
450
+ if (!projectId) {
451
+ emitError("NO_PROJECT", "No linked project.", "Run `bata link <project>` first, or pass --project <id>.");
452
+ }
453
+ const token = requireToken();
454
+ const config = loadConfig();
455
+ const s = jsonMode ? null : spinner(`Resolving branch ${colors.cyan(ref)}`);
456
+ const branch = await resolveBranchRef(projectId, token, config.defaultTeam, ref);
457
+ s?.stop();
458
+ if (!branch) {
459
+ emitError("BRANCH_NOT_FOUND", `Branch "${ref}" not found in this project.`, "List branches with: bata db branches --json");
460
+ }
461
+ // Write into the nearest existing link file's directory if there is one
462
+ // (so `checkout` updates the same link `link`/`query` read); else create one
463
+ // in the CWD.
464
+ const existing = findLinkFile();
465
+ const targetDir = existing ? path.dirname(path.dirname(existing)) : process.cwd();
466
+ const linkFile = writeLinkFile(targetDir, { projectId, branchId: branch.id });
467
+ if (jsonMode) {
468
+ json({
469
+ project_id: projectId,
470
+ branch_id: branch.id,
471
+ branch_name: branch.name,
472
+ link_file: linkFile,
473
+ });
474
+ return;
475
+ }
476
+ log();
477
+ success(`Checked out branch ${colors.cyan(branch.name)} ${colors.dim(branch.id)}`);
478
+ log(` ${colors.dim("Project:")} ${colors.dim(projectId)}`);
479
+ log(` ${colors.dim("Link file:")} ${colors.dim(linkFile)}`);
480
+ log();
481
+ }
482
+ export async function studio() {
483
+ const projectId = resolveProjectId().projectId;
281
484
  const studioUrl = projectId
282
485
  ? `https://bench-app-one.vercel.app/studio?project=${projectId}`
283
486
  : "https://bench-app-one.vercel.app/studio";
@@ -313,14 +516,17 @@ function parseBranchFlag(args) {
313
516
  }
314
517
  export async function query(args = []) {
315
518
  const jsonMode = isJsonMode();
316
- const { branchId, rest } = parseBranchFlag(args);
519
+ const { branchId: branchFlag, rest } = parseBranchFlag(args);
520
+ // Branch precedence: an explicit --branch wins, else the branch pinned by
521
+ // `bata db branch checkout` in .batadata/project.json, else the primary.
522
+ const branchId = resolveBranchId(branchFlag).branchId;
317
523
  const sql = rest.join(" ").trim();
318
524
  if (!sql) {
319
525
  emitError("MISSING_ARG", "SQL query is required.", 'Usage: bata db query "SELECT 1"');
320
526
  }
321
527
  const token = requireToken();
322
528
  const config = loadConfig();
323
- const projectId = config.defaultProject;
529
+ const projectId = resolveProjectId().projectId;
324
530
  if (!projectId) {
325
531
  emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
326
532
  }
@@ -442,10 +648,20 @@ function dbHelp(sub) {
442
648
  note("--json includes computeStatus + ready — poll these after create/cold start.");
443
649
  break;
444
650
  case "branch":
445
- log(` ${colors.bold("bata db branch")} — create or delete a branch`);
651
+ log(` ${colors.bold("bata db branch")} — create, delete, or check out a branch`);
446
652
  log();
447
- usage("bata db branch create <name>");
653
+ usage("bata db branch create <name> [--expires-in <2h|30m|7d>] [--purpose <text>]");
448
654
  usage("bata db branch delete <name> [--yes]");
655
+ usage("bata db branch checkout <name-or-id> [--project <id>]");
656
+ usage("bata db branch protect <name-or-id>");
657
+ usage("bata db branch unprotect <name-or-id>");
658
+ log();
659
+ note("--expires-in Auto-delete the branch after this long (units: s/m/h/d/w).");
660
+ note("--purpose Free-text note describing why the branch exists.");
661
+ note("checkout pins the branch into .batadata/project.json so db query/url");
662
+ note("target it without a --branch flag. Needs a linked project (bata link).");
663
+ note("protect/unprotect lock a branch against delete/reset/rollback/auto-reap.");
664
+ note("A protected branch cannot also carry a TTL (they're mutually exclusive).");
449
665
  break;
450
666
  case "url":
451
667
  log(` ${colors.bold("bata db url")} — print the connection string`);
@@ -471,6 +687,9 @@ function dbHelp(sub) {
471
687
  usage("bata db branches List branches + compute status");
472
688
  usage("bata db branch create Create a new branch");
473
689
  usage("bata db branch delete Delete a branch");
690
+ usage("bata db branch checkout Pin a branch into .batadata/project.json");
691
+ usage("bata db branch protect Lock a branch against destructive ops");
692
+ usage("bata db branch unprotect Remove a branch's protection");
474
693
  usage("bata db studio Open the table browser");
475
694
  usage("bata db query <sql> Run a SQL query (--branch <id-or-name> to target a branch)");
476
695
  log();
@@ -497,10 +716,16 @@ export async function handleDb(args) {
497
716
  case "branch": {
498
717
  const action = args[1];
499
718
  if (action === "create")
500
- return branchCreate(args[2]);
719
+ return branchCreate(args.slice(2));
501
720
  if (action === "delete")
502
721
  return branchDelete(args[2]);
503
- emitError("INVALID_FLAG", `Unknown: db branch ${action || ""}`, "Available: create, delete");
722
+ if (action === "checkout")
723
+ return branchCheckout(args.slice(2));
724
+ if (action === "protect")
725
+ return branchSetProtected(args[2], true);
726
+ if (action === "unprotect")
727
+ return branchSetProtected(args[2], false);
728
+ emitError("INVALID_FLAG", `Unknown: db branch ${action || ""}`, "Available: create, delete, checkout, protect, unprotect");
504
729
  }
505
730
  case "studio":
506
731
  return studio();
@@ -0,0 +1,2 @@
1
+ export declare function link(args: string[]): Promise<void>;
2
+ export declare function unlink(): void;