@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 +81 -0
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/commands/connect.js +4 -2
- package/dist/commands/db.d.ts +35 -1
- package/dist/commands/db.js +242 -17
- package/dist/commands/link.d.ts +2 -0
- package/dist/commands/link.js +138 -0
- package/dist/commands/projects.js +6 -4
- package/dist/commands/restore.d.ts +71 -0
- package/dist/commands/restore.js +356 -0
- package/dist/commands/schema.js +5 -4
- package/dist/index.js +19 -0
- package/dist/link.d.ts +107 -0
- package/dist/link.js +170 -0
- package/package.json +1 -1
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
|
};
|
package/dist/commands/connect.js
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/commands/db.d.ts
CHANGED
|
@@ -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(
|
|
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>;
|
package/dist/commands/db.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
251
|
+
export async function branchCreate(args = []) {
|
|
187
252
|
const jsonMode = isJsonMode();
|
|
188
253
|
const token = requireToken();
|
|
189
254
|
const config = loadConfig();
|
|
190
|
-
const projectId =
|
|
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: {
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
719
|
+
return branchCreate(args.slice(2));
|
|
501
720
|
if (action === "delete")
|
|
502
721
|
return branchDelete(args[2]);
|
|
503
|
-
|
|
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();
|