@batadata/cli 0.1.8 → 0.1.10
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 +34 -0
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/commands/db.d.ts +7 -0
- package/dist/commands/db.js +204 -13
- package/dist/commands/restore.d.ts +71 -0
- package/dist/commands/restore.js +356 -0
- package/dist/commands/schema.d.ts +5 -0
- package/dist/commands/schema.js +15 -2
- package/dist/commands/usage.d.ts +11 -0
- package/dist/commands/usage.js +122 -3
- package/dist/index.js +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -102,6 +102,8 @@ flag always wins**, so linking never silently overrides an intentional request:
|
|
|
102
102
|
| `db branch create` / `db branch delete` | Manage branches |
|
|
103
103
|
| `db branch checkout <name-or-id>` | Pin a branch into the directory's link |
|
|
104
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 |
|
|
105
107
|
| `schema check <file>` | Check a proposed schema change against live query traffic |
|
|
106
108
|
| `generate` | Generate types from your database schema (`--watch` for watch mode) |
|
|
107
109
|
| `dev` | Print the local development setup guide |
|
|
@@ -113,6 +115,38 @@ safety.
|
|
|
113
115
|
|
|
114
116
|
Run `bata --help` for the full, authoritative command list, or `bata --version`.
|
|
115
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
|
+
|
|
116
150
|
## Headless / agent use
|
|
117
151
|
|
|
118
152
|
Every command runs headlessly with just `BATA_API_KEY` set — no `bata login`,
|
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/db.d.ts
CHANGED
|
@@ -23,6 +23,13 @@ export declare function url(): Promise<void>;
|
|
|
23
23
|
export declare function branches(): Promise<void>;
|
|
24
24
|
export declare function branchCreate(args?: string[]): Promise<void>;
|
|
25
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>;
|
|
26
33
|
/**
|
|
27
34
|
* `bata db branch checkout <name-or-id>` — pin a branch into the directory's
|
|
28
35
|
* `.batadata/project.json` so later `db query` / `db url` target it with no
|
package/dist/commands/db.js
CHANGED
|
@@ -7,6 +7,9 @@ import { prompt, confirmDestructive } from "../utils/prompts.js";
|
|
|
7
7
|
import { openBrowser } from "../utils/open.js";
|
|
8
8
|
import { emitError, isRetryable } from "../utils/errors.js";
|
|
9
9
|
import { resolveProjectId, resolveBranchId, findLinkFile, writeLinkFile } from "../link.js";
|
|
10
|
+
// Share the exact LSN/timestamp discriminator `bata restore` uses so `db query
|
|
11
|
+
// --at` classifies a restore point identically.
|
|
12
|
+
import { classifyRestorePoint } from "./restore.js";
|
|
10
13
|
async function getConnectionInfo(projectId, token) {
|
|
11
14
|
// reveal=true so the returned string is actually usable (the owner is asking).
|
|
12
15
|
const res = await api.get(`/v1/connection-info/${projectId}`, token, { reveal: "true" });
|
|
@@ -219,6 +222,8 @@ export async function branches() {
|
|
|
219
222
|
// Ephemeral-branch TTL: when set, the branch is auto-reaped after this.
|
|
220
223
|
expires_at: b.expiresAt ?? null,
|
|
221
224
|
purpose: b.purpose ?? null,
|
|
225
|
+
// Protection flag — a protected branch refuses delete/reset/rollback/reap.
|
|
226
|
+
protected: b.isProtected ?? false,
|
|
222
227
|
created_at: b.createdAt ?? null,
|
|
223
228
|
})),
|
|
224
229
|
count: branchList.length,
|
|
@@ -231,9 +236,12 @@ export async function branches() {
|
|
|
231
236
|
log();
|
|
232
237
|
return;
|
|
233
238
|
}
|
|
234
|
-
table(["NAME", "PRIMARY", "STATUS", "EXPIRES", "CREATED"], branchList.map((b) => [
|
|
239
|
+
table(["NAME", "PRIMARY", "PROTECTED", "STATUS", "EXPIRES", "CREATED"], branchList.map((b) => [
|
|
235
240
|
b.name,
|
|
236
241
|
b.isPrimary ? colors.green("yes") : "-",
|
|
242
|
+
// Protected branches are locked against destructive ops — flag them so the
|
|
243
|
+
// column is scannable.
|
|
244
|
+
b.isProtected ? colors.yellow("locked") : "-",
|
|
237
245
|
// Live compute lifecycle (computeStatus), with a check once ready so the
|
|
238
246
|
// column is scannable.
|
|
239
247
|
branchStatusLabel(b),
|
|
@@ -359,6 +367,56 @@ export async function branchDelete(name) {
|
|
|
359
367
|
log(` ${colors.green(">")} Branch ${colors.cyan(name)} deleted`);
|
|
360
368
|
log();
|
|
361
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* `bata db branch protect <name-or-id>` / `unprotect <name-or-id>` — flip a
|
|
372
|
+
* branch's protection flag (PATCH /v1/branches/:id). A protected branch refuses
|
|
373
|
+
* delete/reset/rollback/reap — the safety rail for headless/agent ops. The ref
|
|
374
|
+
* is resolved by name OR id, like the other branch commands.
|
|
375
|
+
*/
|
|
376
|
+
export async function branchSetProtected(ref, wantProtected) {
|
|
377
|
+
const jsonMode = isJsonMode();
|
|
378
|
+
const token = requireToken();
|
|
379
|
+
const config = loadConfig();
|
|
380
|
+
const projectId = resolveProjectId().projectId;
|
|
381
|
+
const verb = wantProtected ? "protect" : "unprotect";
|
|
382
|
+
if (!projectId) {
|
|
383
|
+
emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
|
|
384
|
+
}
|
|
385
|
+
if (!ref) {
|
|
386
|
+
emitError("MISSING_ARG", "Branch name or id is required.", `Usage: bata db branch ${verb} <name-or-id>`);
|
|
387
|
+
}
|
|
388
|
+
const s = jsonMode ? null : spinner(`Resolving branch ${colors.cyan(ref)}`);
|
|
389
|
+
const branch = await resolveBranchRef(projectId, token, config.defaultTeam, ref);
|
|
390
|
+
s?.stop();
|
|
391
|
+
if (!branch) {
|
|
392
|
+
emitError("BRANCH_NOT_FOUND", `Branch "${ref}" not found in this project.`, "List branches with: bata db branches --json");
|
|
393
|
+
}
|
|
394
|
+
const s2 = jsonMode ? null : spinner(`${wantProtected ? "Protecting" : "Unprotecting"} branch ${colors.cyan(branch.name)}`);
|
|
395
|
+
const query = {};
|
|
396
|
+
if (config.defaultTeam)
|
|
397
|
+
query.team_id = config.defaultTeam;
|
|
398
|
+
const res = await api.patch(`/v1/branches/${branch.id}`, { protected: wantProtected }, token, query);
|
|
399
|
+
s2?.stop();
|
|
400
|
+
if (!res.ok) {
|
|
401
|
+
emitError(res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE" : "CLI_ERROR", apiError(res, `Failed to ${verb} branch.`), "");
|
|
402
|
+
}
|
|
403
|
+
if (jsonMode) {
|
|
404
|
+
json({
|
|
405
|
+
branch: { id: branch.id, name: branch.name, project_id: projectId },
|
|
406
|
+
protected: res.data.isProtected ?? wantProtected,
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
log();
|
|
411
|
+
if (wantProtected) {
|
|
412
|
+
log(` ${colors.green(">")} Branch ${colors.cyan(branch.name)} is now ${colors.yellow("protected")}`);
|
|
413
|
+
log(` ${colors.dim("It can't be deleted, reset, rolled back, or auto-reaped until unprotected.")}`);
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
log(` ${colors.green(">")} Branch ${colors.cyan(branch.name)} is no longer protected`);
|
|
417
|
+
}
|
|
418
|
+
log();
|
|
419
|
+
}
|
|
362
420
|
/**
|
|
363
421
|
* Pull a `--project <id>` / `--project=<id>` flag out of args, returning the
|
|
364
422
|
* explicit project id (if any) and the remaining positionals. Lets `db branch
|
|
@@ -436,14 +494,14 @@ export async function studio() {
|
|
|
436
494
|
openBrowser(studioUrl);
|
|
437
495
|
}
|
|
438
496
|
/**
|
|
439
|
-
* Pull
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
* with how global flags are parsed.
|
|
497
|
+
* Pull `--branch <ref>` / `--at <ISO-timestamp|LSN>` flags (and their `=` forms)
|
|
498
|
+
* out of the query args and return them plus the remaining args, which join into
|
|
499
|
+
* the SQL string. Keeps `db query` order-independent (flags can sit before or
|
|
500
|
+
* after the SQL) and consistent with how global flags are parsed.
|
|
444
501
|
*/
|
|
445
|
-
function
|
|
502
|
+
function parseQueryFlags(args) {
|
|
446
503
|
let branchId;
|
|
504
|
+
let at;
|
|
447
505
|
const rest = [];
|
|
448
506
|
for (let i = 0; i < args.length; i++) {
|
|
449
507
|
const arg = args[i];
|
|
@@ -453,15 +511,117 @@ function parseBranchFlag(args) {
|
|
|
453
511
|
else if (arg.startsWith("--branch=")) {
|
|
454
512
|
branchId = arg.slice("--branch=".length);
|
|
455
513
|
}
|
|
514
|
+
else if (arg === "--at") {
|
|
515
|
+
at = args[++i];
|
|
516
|
+
}
|
|
517
|
+
else if (arg.startsWith("--at=")) {
|
|
518
|
+
at = arg.slice("--at=".length);
|
|
519
|
+
}
|
|
456
520
|
else {
|
|
457
521
|
rest.push(arg);
|
|
458
522
|
}
|
|
459
523
|
}
|
|
460
|
-
return { branchId, rest };
|
|
524
|
+
return { branchId, at, rest };
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Resolve the branch a query should target: an explicit `--branch <id-or-name>`
|
|
528
|
+
* (resolved against the project's branch list) or the project's primary branch.
|
|
529
|
+
* Exits (via emitError) with BRANCH_NOT_FOUND if nothing matches.
|
|
530
|
+
*/
|
|
531
|
+
async function resolveQueryBranchId(projectId, token, teamId, branchRef) {
|
|
532
|
+
if (branchRef) {
|
|
533
|
+
const branch = await resolveBranchRef(projectId, token, teamId, branchRef);
|
|
534
|
+
if (!branch) {
|
|
535
|
+
emitError("BRANCH_NOT_FOUND", `Branch "${branchRef}" not found in this project.`, "List branches with: bata db branches --json");
|
|
536
|
+
}
|
|
537
|
+
return branch.id;
|
|
538
|
+
}
|
|
539
|
+
const branch = await getPrimaryBranch(projectId, token, teamId);
|
|
540
|
+
if (!branch) {
|
|
541
|
+
emitError("BRANCH_NOT_FOUND", "No branch found for this project to run the query against.", "Check the project with: bata db branches --json");
|
|
542
|
+
}
|
|
543
|
+
return branch.id;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Run a time-travel query: POST /v1/time-travel/query, which forks a hidden
|
|
547
|
+
* ephemeral branch AS OF the point and runs the SQL there. Handles the cold-fork
|
|
548
|
+
* 503 contract — the response always carries the ephemeral `branch` handle so an
|
|
549
|
+
* agent can back off (exit 6) and retry the query itself via
|
|
550
|
+
* `bata db query <sql> --branch <returned-id>`.
|
|
551
|
+
*/
|
|
552
|
+
async function runTimeTravelQuery(opts) {
|
|
553
|
+
const { projectId, branchId, point, sql, token, jsonMode } = opts;
|
|
554
|
+
const s = jsonMode ? null : spinner(`Running query as of ${point.raw}`);
|
|
555
|
+
const res = await api.post("/v1/time-travel/query", { project_id: projectId, branch_id: branchId, at: point.value, query: sql }, token);
|
|
556
|
+
s?.stop();
|
|
557
|
+
if (!res.ok) {
|
|
558
|
+
const body = res.data;
|
|
559
|
+
// Cold fork: the ephemeral branch is still starting. Retryable (exit 6). The
|
|
560
|
+
// branch handle rides along so the agent can retry against it via /v1/sql.
|
|
561
|
+
if (isRetryable({ status: res.status, code: body?.code, message: body?.error })) {
|
|
562
|
+
if (jsonMode) {
|
|
563
|
+
json({
|
|
564
|
+
error: apiError(res, "Time-travel branch is starting"),
|
|
565
|
+
code: body?.code ?? "COMPUTE_STARTING",
|
|
566
|
+
hint: "retry the query against the returned branch: bata db query <sql> --branch <branch.id>",
|
|
567
|
+
branch: body?.branch ?? null,
|
|
568
|
+
});
|
|
569
|
+
process.exit(6);
|
|
570
|
+
}
|
|
571
|
+
emitError("COMPUTE_STARTING", apiError(res, "Time-travel branch is starting"), body?.branch
|
|
572
|
+
? `compute is starting; retry in a few seconds with --branch ${body.branch.id}`
|
|
573
|
+
: "compute is starting; retry in a few seconds");
|
|
574
|
+
}
|
|
575
|
+
const code = res.status === 401 || res.status === 403 ? "INVALID_KEY" : "CLI_ERROR";
|
|
576
|
+
emitError(code, apiError(res, "Time-travel query failed"), "");
|
|
577
|
+
}
|
|
578
|
+
// The route returns an error field only on a real failure it couldn't map to a
|
|
579
|
+
// status; treat it as an error, not a silent success.
|
|
580
|
+
if (res.data.error) {
|
|
581
|
+
emitError("CLI_ERROR", res.data.error, "");
|
|
582
|
+
}
|
|
583
|
+
const columns = res.data.columns ?? [];
|
|
584
|
+
const rows = res.data.rows ?? [];
|
|
585
|
+
const rowCount = res.data.rowCount ?? rows.length;
|
|
586
|
+
const branch = res.data.branch ?? null;
|
|
587
|
+
if (jsonMode) {
|
|
588
|
+
json({
|
|
589
|
+
columns,
|
|
590
|
+
rows,
|
|
591
|
+
row_count: rowCount,
|
|
592
|
+
command: res.data.command ?? null,
|
|
593
|
+
at: point.value,
|
|
594
|
+
// The ephemeral branch the query ran against (auto-reaped after ~10 min).
|
|
595
|
+
branch,
|
|
596
|
+
});
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (columns.length && rows.length) {
|
|
600
|
+
log();
|
|
601
|
+
table(columns.map((c) => c.toUpperCase()), rows.map((r) => columns.map((col) => fmtCell(r[col]))));
|
|
602
|
+
log();
|
|
603
|
+
log(` ${colors.dim(`${rowCount} row(s) — as of ${point.raw}`)}`);
|
|
604
|
+
}
|
|
605
|
+
else if (rows.length) {
|
|
606
|
+
const keys = Object.keys(rows[0]);
|
|
607
|
+
log();
|
|
608
|
+
table(keys.map((k) => k.toUpperCase()), rows.map((r) => keys.map((k) => fmtCell(r[k]))));
|
|
609
|
+
log();
|
|
610
|
+
log(` ${colors.dim(`${rowCount} row(s) — as of ${point.raw}`)}`);
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
log();
|
|
614
|
+
const cmd = res.data.command ? `${res.data.command} ` : "";
|
|
615
|
+
log(` ${colors.dim(`${cmd}OK — ${rowCount} row(s), as of ${point.raw}.`)}`);
|
|
616
|
+
}
|
|
617
|
+
if (branch) {
|
|
618
|
+
log(` ${colors.dim(`Ran on ephemeral branch ${branch.id} (auto-deleted ~${new Date(branch.expires_at).toLocaleTimeString()}).`)}`);
|
|
619
|
+
}
|
|
620
|
+
log();
|
|
461
621
|
}
|
|
462
622
|
export async function query(args = []) {
|
|
463
623
|
const jsonMode = isJsonMode();
|
|
464
|
-
const { branchId: branchFlag, rest } =
|
|
624
|
+
const { branchId: branchFlag, at, rest } = parseQueryFlags(args);
|
|
465
625
|
// Branch precedence: an explicit --branch wins, else the branch pinned by
|
|
466
626
|
// `bata db branch checkout` in .batadata/project.json, else the primary.
|
|
467
627
|
const branchId = resolveBranchId(branchFlag).branchId;
|
|
@@ -475,6 +635,24 @@ export async function query(args = []) {
|
|
|
475
635
|
if (!projectId) {
|
|
476
636
|
emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
|
|
477
637
|
}
|
|
638
|
+
// `--at` is a time-travel query: fork a hidden ephemeral branch AS OF the point
|
|
639
|
+
// and run the SQL there. Classify the point up front so a bad --at fails fast.
|
|
640
|
+
if (at !== undefined) {
|
|
641
|
+
const classified = classifyRestorePoint(at);
|
|
642
|
+
if (classified.error) {
|
|
643
|
+
emitError("INVALID_FLAG", classified.error, "Pass --at <ISO-timestamp|LSN> (an LSN looks like 0/15994B0).");
|
|
644
|
+
}
|
|
645
|
+
// Resolve the SOURCE branch to fork from (id or name), like the normal path.
|
|
646
|
+
const sourceBranchId = await resolveQueryBranchId(projectId, token, config.defaultTeam, branchId);
|
|
647
|
+
return runTimeTravelQuery({
|
|
648
|
+
projectId,
|
|
649
|
+
branchId: sourceBranchId,
|
|
650
|
+
point: classified.point,
|
|
651
|
+
sql,
|
|
652
|
+
token,
|
|
653
|
+
jsonMode,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
478
656
|
const s = jsonMode ? null : spinner("Running query");
|
|
479
657
|
// Target branch: an explicit --branch <id-or-name> wins; otherwise resolve
|
|
480
658
|
// the project's primary branch. /v1/sql/execute is keyed on branch_id, not
|
|
@@ -577,12 +755,15 @@ function dbHelp(sub) {
|
|
|
577
755
|
log(` ${colors.bold("bata db query")} — run a SQL query against a branch`);
|
|
578
756
|
log();
|
|
579
757
|
usage('bata db query "SELECT 1"');
|
|
580
|
-
usage("bata db query <sql> [--branch <id-or-name>] [--json]");
|
|
758
|
+
usage("bata db query <sql> [--branch <id-or-name>] [--at <timestamp|LSN>] [--json]");
|
|
581
759
|
log();
|
|
582
760
|
note("--branch <id-or-name> Target a specific branch by id OR name (default: the project's primary)");
|
|
761
|
+
note("--at <timestamp|LSN> Time-travel: run the query AS OF a past point (ISO-8601");
|
|
762
|
+
note(" timestamp e.g. 2026-07-04T12:00:00Z, or an LSN e.g. 0/15994B0).");
|
|
763
|
+
note(" Forks a hidden ephemeral branch (auto-deleted ~10 min).");
|
|
583
764
|
note("--json Emit rows as JSON objects + row_count");
|
|
584
|
-
note("Cold-start note: a just-created/idle branch
|
|
585
|
-
note("(retryable) while its compute wakes — retry in a few seconds.");
|
|
765
|
+
note("Cold-start note: a just-created/idle branch (or a fresh --at fork) may answer");
|
|
766
|
+
note("with exit 6 (retryable) while its compute wakes — retry in a few seconds.");
|
|
586
767
|
break;
|
|
587
768
|
case "branches":
|
|
588
769
|
log(` ${colors.bold("bata db branches")} — list branches and their compute status`);
|
|
@@ -598,11 +779,15 @@ function dbHelp(sub) {
|
|
|
598
779
|
usage("bata db branch create <name> [--expires-in <2h|30m|7d>] [--purpose <text>]");
|
|
599
780
|
usage("bata db branch delete <name> [--yes]");
|
|
600
781
|
usage("bata db branch checkout <name-or-id> [--project <id>]");
|
|
782
|
+
usage("bata db branch protect <name-or-id>");
|
|
783
|
+
usage("bata db branch unprotect <name-or-id>");
|
|
601
784
|
log();
|
|
602
785
|
note("--expires-in Auto-delete the branch after this long (units: s/m/h/d/w).");
|
|
603
786
|
note("--purpose Free-text note describing why the branch exists.");
|
|
604
787
|
note("checkout pins the branch into .batadata/project.json so db query/url");
|
|
605
788
|
note("target it without a --branch flag. Needs a linked project (bata link).");
|
|
789
|
+
note("protect/unprotect lock a branch against delete/reset/rollback/auto-reap.");
|
|
790
|
+
note("A protected branch cannot also carry a TTL (they're mutually exclusive).");
|
|
606
791
|
break;
|
|
607
792
|
case "url":
|
|
608
793
|
log(` ${colors.bold("bata db url")} — print the connection string`);
|
|
@@ -629,6 +814,8 @@ function dbHelp(sub) {
|
|
|
629
814
|
usage("bata db branch create Create a new branch");
|
|
630
815
|
usage("bata db branch delete Delete a branch");
|
|
631
816
|
usage("bata db branch checkout Pin a branch into .batadata/project.json");
|
|
817
|
+
usage("bata db branch protect Lock a branch against destructive ops");
|
|
818
|
+
usage("bata db branch unprotect Remove a branch's protection");
|
|
632
819
|
usage("bata db studio Open the table browser");
|
|
633
820
|
usage("bata db query <sql> Run a SQL query (--branch <id-or-name> to target a branch)");
|
|
634
821
|
log();
|
|
@@ -660,7 +847,11 @@ export async function handleDb(args) {
|
|
|
660
847
|
return branchDelete(args[2]);
|
|
661
848
|
if (action === "checkout")
|
|
662
849
|
return branchCheckout(args.slice(2));
|
|
663
|
-
|
|
850
|
+
if (action === "protect")
|
|
851
|
+
return branchSetProtected(args[2], true);
|
|
852
|
+
if (action === "unprotect")
|
|
853
|
+
return branchSetProtected(args[2], false);
|
|
854
|
+
emitError("INVALID_FLAG", `Unknown: db branch ${action || ""}`, "Available: create, delete, checkout, protect, unprotect");
|
|
664
855
|
}
|
|
665
856
|
case "studio":
|
|
666
857
|
return studio();
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bata restore — Point-in-time restore (PITR).
|
|
3
|
+
*
|
|
4
|
+
* Two subcommands:
|
|
5
|
+
* restore points [--project <id>] [--json]
|
|
6
|
+
* List the recovery points recorded for a project (GET
|
|
7
|
+
* /v1/recovery-points/:projectId) plus each branch's live LSN and the
|
|
8
|
+
* honest PITR window.
|
|
9
|
+
*
|
|
10
|
+
* restore create --branch <name-or-id> --at <ISO-timestamp|LSN>
|
|
11
|
+
* [--name <newBranchName>] [--project <id>] [--json]
|
|
12
|
+
* Restore a branch to a point in time by creating a NEW branch at that
|
|
13
|
+
* point (POST /v1/restore). This is non-destructive: the source branch's
|
|
14
|
+
* data is never overwritten.
|
|
15
|
+
*
|
|
16
|
+
* HONESTY RULE (documented, never overclaimed):
|
|
17
|
+
* Recovery points are timeline-metadata snapshots the backup scheduler records
|
|
18
|
+
* about every 6 hours (BACKUP_INTERVAL_HOURS) — they are NOT full physical
|
|
19
|
+
* backups, and the earliest listed point is the oldest metadata row we have,
|
|
20
|
+
* not a guaranteed floor on how far back you can restore. Restorability to an
|
|
21
|
+
* arbitrary timestamp depends on how much WAL the pageserver still retains; if
|
|
22
|
+
* the WAL doesn't extend that far the server returns "No data available at the
|
|
23
|
+
* requested timestamp." We surface that verbatim rather than pretending the
|
|
24
|
+
* window is deeper than it is.
|
|
25
|
+
*/
|
|
26
|
+
export interface RestorePoint {
|
|
27
|
+
/** How the input was interpreted. */
|
|
28
|
+
kind: "lsn" | "timestamp";
|
|
29
|
+
/**
|
|
30
|
+
* The value to send to the server. For an LSN it's the input as-is; for a
|
|
31
|
+
* timestamp it's normalized to offset-aware ISO 8601 so the server's
|
|
32
|
+
* `datetime({ offset: true })` validation accepts it.
|
|
33
|
+
*/
|
|
34
|
+
value: string;
|
|
35
|
+
/** The raw user input (for human display). */
|
|
36
|
+
raw: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Classify a `--at` value as an LSN or a timestamp. An LSN (`0/15994B0`) is
|
|
40
|
+
* matched structurally; anything else is parsed as a date and normalized to
|
|
41
|
+
* offset-aware ISO 8601. Returns `{ error }` for input that is neither.
|
|
42
|
+
* Exported for unit testing.
|
|
43
|
+
*/
|
|
44
|
+
export declare function classifyRestorePoint(input: string): {
|
|
45
|
+
point?: RestorePoint;
|
|
46
|
+
error?: string;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Parse `restore create` args: `--branch`, `--at`, `--name`, `--project`
|
|
50
|
+
* (each accepting both `--flag value` and `--flag=value`). Order-independent,
|
|
51
|
+
* mirroring `parseBranchCreateArgs`. Exported for unit testing.
|
|
52
|
+
*/
|
|
53
|
+
export declare function parseRestoreCreateArgs(args: string[]): {
|
|
54
|
+
branch?: string;
|
|
55
|
+
at?: string;
|
|
56
|
+
name?: string;
|
|
57
|
+
projectId?: string;
|
|
58
|
+
};
|
|
59
|
+
/** Parse `restore points` args — only `--project` is meaningful here. */
|
|
60
|
+
export declare function parseRestorePointsArgs(args: string[]): {
|
|
61
|
+
projectId?: string;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* A default name for the restored branch when `--name` is omitted. Includes a
|
|
65
|
+
* compact UTC stamp so repeated restores don't collide, e.g.
|
|
66
|
+
* `restore-main-20260704T120000Z`.
|
|
67
|
+
*/
|
|
68
|
+
export declare function defaultRestoreBranchName(sourceName: string, now?: Date): string;
|
|
69
|
+
export declare function restorePoints(args: string[]): Promise<void>;
|
|
70
|
+
export declare function restoreCreate(args: string[]): Promise<void>;
|
|
71
|
+
export declare function handleRestore(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bata restore — Point-in-time restore (PITR).
|
|
3
|
+
*
|
|
4
|
+
* Two subcommands:
|
|
5
|
+
* restore points [--project <id>] [--json]
|
|
6
|
+
* List the recovery points recorded for a project (GET
|
|
7
|
+
* /v1/recovery-points/:projectId) plus each branch's live LSN and the
|
|
8
|
+
* honest PITR window.
|
|
9
|
+
*
|
|
10
|
+
* restore create --branch <name-or-id> --at <ISO-timestamp|LSN>
|
|
11
|
+
* [--name <newBranchName>] [--project <id>] [--json]
|
|
12
|
+
* Restore a branch to a point in time by creating a NEW branch at that
|
|
13
|
+
* point (POST /v1/restore). This is non-destructive: the source branch's
|
|
14
|
+
* data is never overwritten.
|
|
15
|
+
*
|
|
16
|
+
* HONESTY RULE (documented, never overclaimed):
|
|
17
|
+
* Recovery points are timeline-metadata snapshots the backup scheduler records
|
|
18
|
+
* about every 6 hours (BACKUP_INTERVAL_HOURS) — they are NOT full physical
|
|
19
|
+
* backups, and the earliest listed point is the oldest metadata row we have,
|
|
20
|
+
* not a guaranteed floor on how far back you can restore. Restorability to an
|
|
21
|
+
* arbitrary timestamp depends on how much WAL the pageserver still retains; if
|
|
22
|
+
* the WAL doesn't extend that far the server returns "No data available at the
|
|
23
|
+
* requested timestamp." We surface that verbatim rather than pretending the
|
|
24
|
+
* window is deeper than it is.
|
|
25
|
+
*/
|
|
26
|
+
import { api, apiError } from "../api.js";
|
|
27
|
+
import { requireToken, loadConfig, isJsonMode } from "../config.js";
|
|
28
|
+
import { colors, log, json, spinner, table, heading, kvList, success } from "../utils/logger.js";
|
|
29
|
+
import { emitError, isRetryable } from "../utils/errors.js";
|
|
30
|
+
import { resolveProjectId } from "../link.js";
|
|
31
|
+
// A Postgres LSN is two hex words joined by a slash, e.g. `0/15994B0`. That
|
|
32
|
+
// shape can never collide with an ISO-8601 timestamp (which carries `-`/`:`),
|
|
33
|
+
// so the slash-of-hex is a safe discriminator.
|
|
34
|
+
const LSN_RE = /^[0-9A-Fa-f]+\/[0-9A-Fa-f]+$/;
|
|
35
|
+
/**
|
|
36
|
+
* Classify a `--at` value as an LSN or a timestamp. An LSN (`0/15994B0`) is
|
|
37
|
+
* matched structurally; anything else is parsed as a date and normalized to
|
|
38
|
+
* offset-aware ISO 8601. Returns `{ error }` for input that is neither.
|
|
39
|
+
* Exported for unit testing.
|
|
40
|
+
*/
|
|
41
|
+
export function classifyRestorePoint(input) {
|
|
42
|
+
const raw = (input ?? "").trim();
|
|
43
|
+
if (!raw) {
|
|
44
|
+
return { error: "A restore point is required. Pass --at <ISO-timestamp|LSN>." };
|
|
45
|
+
}
|
|
46
|
+
if (LSN_RE.test(raw)) {
|
|
47
|
+
return { point: { kind: "lsn", value: raw, raw } };
|
|
48
|
+
}
|
|
49
|
+
const d = new Date(raw);
|
|
50
|
+
if (!Number.isNaN(d.getTime())) {
|
|
51
|
+
return { point: { kind: "timestamp", value: d.toISOString(), raw } };
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
error: `Could not parse "${raw}" as an ISO-8601 timestamp or an LSN (e.g. 0/15994B0).`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Parse `restore create` args: `--branch`, `--at`, `--name`, `--project`
|
|
59
|
+
* (each accepting both `--flag value` and `--flag=value`). Order-independent,
|
|
60
|
+
* mirroring `parseBranchCreateArgs`. Exported for unit testing.
|
|
61
|
+
*/
|
|
62
|
+
export function parseRestoreCreateArgs(args) {
|
|
63
|
+
let branch;
|
|
64
|
+
let at;
|
|
65
|
+
let name;
|
|
66
|
+
let projectId;
|
|
67
|
+
for (let i = 0; i < args.length; i++) {
|
|
68
|
+
const arg = args[i];
|
|
69
|
+
if (arg === "--branch")
|
|
70
|
+
branch = args[++i];
|
|
71
|
+
else if (arg.startsWith("--branch="))
|
|
72
|
+
branch = arg.slice("--branch=".length);
|
|
73
|
+
else if (arg === "--at")
|
|
74
|
+
at = args[++i];
|
|
75
|
+
else if (arg.startsWith("--at="))
|
|
76
|
+
at = arg.slice("--at=".length);
|
|
77
|
+
else if (arg === "--name")
|
|
78
|
+
name = args[++i];
|
|
79
|
+
else if (arg.startsWith("--name="))
|
|
80
|
+
name = arg.slice("--name=".length);
|
|
81
|
+
else if (arg === "--project")
|
|
82
|
+
projectId = args[++i];
|
|
83
|
+
else if (arg.startsWith("--project="))
|
|
84
|
+
projectId = arg.slice("--project=".length);
|
|
85
|
+
}
|
|
86
|
+
return { branch, at, name, projectId };
|
|
87
|
+
}
|
|
88
|
+
/** Parse `restore points` args — only `--project` is meaningful here. */
|
|
89
|
+
export function parseRestorePointsArgs(args) {
|
|
90
|
+
let projectId;
|
|
91
|
+
for (let i = 0; i < args.length; i++) {
|
|
92
|
+
const arg = args[i];
|
|
93
|
+
if (arg === "--project")
|
|
94
|
+
projectId = args[++i];
|
|
95
|
+
else if (arg.startsWith("--project="))
|
|
96
|
+
projectId = arg.slice("--project=".length);
|
|
97
|
+
}
|
|
98
|
+
return { projectId };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A default name for the restored branch when `--name` is omitted. Includes a
|
|
102
|
+
* compact UTC stamp so repeated restores don't collide, e.g.
|
|
103
|
+
* `restore-main-20260704T120000Z`.
|
|
104
|
+
*/
|
|
105
|
+
export function defaultRestoreBranchName(sourceName, now = new Date()) {
|
|
106
|
+
const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
107
|
+
return `restore-${sourceName}-${stamp}`;
|
|
108
|
+
}
|
|
109
|
+
// ─── Shared helpers ────────────────────────────────────────────────────────
|
|
110
|
+
function requireProject(projectFlag) {
|
|
111
|
+
const { projectId } = resolveProjectId(projectFlag);
|
|
112
|
+
if (!projectId) {
|
|
113
|
+
emitError("NO_PROJECT", "No project. Pass --project <id>, or link one with `bata link <project>`.", "Run `bata link <project>` once, then restore commands need no --project.");
|
|
114
|
+
}
|
|
115
|
+
return projectId;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Resolve a `--branch` reference (id OR name) to a concrete branch via
|
|
119
|
+
* GET /v1/projects/:id. Agents pass the same name they branched with, so
|
|
120
|
+
* accepting only ids would be a footgun — mirrors `db query --branch`.
|
|
121
|
+
*/
|
|
122
|
+
async function resolveBranchRef(projectId, token, teamId, ref) {
|
|
123
|
+
const query = {};
|
|
124
|
+
if (teamId)
|
|
125
|
+
query.team_id = teamId;
|
|
126
|
+
const res = await api.get(`/v1/projects/${projectId}`, token, query);
|
|
127
|
+
if (!res.ok)
|
|
128
|
+
return null;
|
|
129
|
+
const branches = res.data.branches ?? [];
|
|
130
|
+
return branches.find((b) => b.id === ref || b.name === ref) ?? null;
|
|
131
|
+
}
|
|
132
|
+
function formatDateTime(iso) {
|
|
133
|
+
if (!iso)
|
|
134
|
+
return "-";
|
|
135
|
+
const d = new Date(iso);
|
|
136
|
+
if (Number.isNaN(d.getTime()))
|
|
137
|
+
return iso;
|
|
138
|
+
return d.toLocaleString("en-US", {
|
|
139
|
+
month: "short",
|
|
140
|
+
day: "numeric",
|
|
141
|
+
year: "numeric",
|
|
142
|
+
hour: "2-digit",
|
|
143
|
+
minute: "2-digit",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function formatBytes(bytes) {
|
|
147
|
+
if (bytes === null || bytes === undefined)
|
|
148
|
+
return "-";
|
|
149
|
+
if (bytes < 1024)
|
|
150
|
+
return `${bytes} B`;
|
|
151
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
152
|
+
let value = bytes / 1024;
|
|
153
|
+
let unit = 0;
|
|
154
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
155
|
+
value /= 1024;
|
|
156
|
+
unit++;
|
|
157
|
+
}
|
|
158
|
+
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
|
|
159
|
+
}
|
|
160
|
+
// ─── restore points ────────────────────────────────────────────────────────
|
|
161
|
+
export async function restorePoints(args) {
|
|
162
|
+
const jsonMode = isJsonMode();
|
|
163
|
+
const token = requireToken();
|
|
164
|
+
const config = loadConfig();
|
|
165
|
+
const { projectId: projectFlag } = parseRestorePointsArgs(args);
|
|
166
|
+
const projectId = requireProject(projectFlag);
|
|
167
|
+
const query = {};
|
|
168
|
+
if (config.defaultTeam)
|
|
169
|
+
query.team_id = config.defaultTeam;
|
|
170
|
+
const s = jsonMode ? null : spinner("Fetching recovery points");
|
|
171
|
+
const res = await api.get(`/v1/recovery-points/${projectId}`, token, query);
|
|
172
|
+
s?.stop();
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
emitError(res.status === 401 || res.status === 403 ? "INVALID_KEY"
|
|
175
|
+
: res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
|
|
176
|
+
: "CLI_ERROR", apiError(res, "Failed to fetch recovery points"), "");
|
|
177
|
+
}
|
|
178
|
+
const data = res.data;
|
|
179
|
+
const points = data.recovery_points ?? [];
|
|
180
|
+
const live = data.live_state ?? [];
|
|
181
|
+
const window = data.pitr_window;
|
|
182
|
+
if (jsonMode) {
|
|
183
|
+
json({
|
|
184
|
+
project_id: data.project_id ?? projectId,
|
|
185
|
+
recovery_points: points,
|
|
186
|
+
live_state: live,
|
|
187
|
+
pitr_window: window,
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Map branch_id → name from live_state so the table shows names, not ids.
|
|
192
|
+
const nameById = new Map(live.map((b) => [b.branch_id, b.branch_name]));
|
|
193
|
+
heading(`Recovery points — ${projectId}`);
|
|
194
|
+
kvList([
|
|
195
|
+
["Earliest recorded", formatDateTime(window?.earliest_available ?? null)],
|
|
196
|
+
["Latest (live)", formatDateTime(window?.latest_available ?? null)],
|
|
197
|
+
]);
|
|
198
|
+
log();
|
|
199
|
+
if (points.length === 0) {
|
|
200
|
+
log(` ${colors.dim("No recorded recovery points yet.")}`);
|
|
201
|
+
log(` ${colors.dim("The backup scheduler records a point about every 6 hours.")}`);
|
|
202
|
+
log();
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
table(["RECORDED", "BRANCH", "LSN", "SIZE", "TYPE"], points.map((p) => [
|
|
206
|
+
formatDateTime(p.recorded_at),
|
|
207
|
+
nameById.get(p.branch_id) ?? p.branch_id,
|
|
208
|
+
p.lsn ?? "-",
|
|
209
|
+
formatBytes(p.size_bytes),
|
|
210
|
+
p.type,
|
|
211
|
+
]));
|
|
212
|
+
log();
|
|
213
|
+
}
|
|
214
|
+
// Honest window note — never overclaim depth. A recorded point is a metadata
|
|
215
|
+
// snapshot, and restorability past it depends on WAL retention.
|
|
216
|
+
log(` ${colors.dim("Recovery points are timeline-metadata snapshots (recorded ~every 6h),")}`);
|
|
217
|
+
log(` ${colors.dim("not full backups. Restore to any timestamp the WAL still covers with:")}`);
|
|
218
|
+
log(` ${colors.cyan('bata restore create --branch <name> --at <ISO-timestamp|LSN>')}`);
|
|
219
|
+
log();
|
|
220
|
+
}
|
|
221
|
+
// ─── restore create ─────────────────────────────────────────────────────────
|
|
222
|
+
export async function restoreCreate(args) {
|
|
223
|
+
const jsonMode = isJsonMode();
|
|
224
|
+
const token = requireToken();
|
|
225
|
+
const config = loadConfig();
|
|
226
|
+
const { branch, at, name, projectId: projectFlag } = parseRestoreCreateArgs(args);
|
|
227
|
+
const projectId = requireProject(projectFlag);
|
|
228
|
+
if (!branch) {
|
|
229
|
+
emitError("MISSING_ARG", "A source branch is required.", "Usage: bata restore create --branch <name-or-id> --at <ISO-timestamp|LSN>");
|
|
230
|
+
}
|
|
231
|
+
if (!at) {
|
|
232
|
+
emitError("MISSING_ARG", "A restore point is required.", "Pass --at <ISO-timestamp|LSN> (an LSN looks like 0/15994B0).");
|
|
233
|
+
}
|
|
234
|
+
const classified = classifyRestorePoint(at);
|
|
235
|
+
if (classified.error) {
|
|
236
|
+
emitError("INVALID_FLAG", classified.error, "");
|
|
237
|
+
}
|
|
238
|
+
const point = classified.point;
|
|
239
|
+
const s = jsonMode ? null : spinner(`Resolving branch ${colors.cyan(branch)}`);
|
|
240
|
+
const sourceBranch = await resolveBranchRef(projectId, token, config.defaultTeam, branch);
|
|
241
|
+
if (!sourceBranch) {
|
|
242
|
+
s?.stop();
|
|
243
|
+
emitError("BRANCH_NOT_FOUND", `Branch "${branch}" not found in this project.`, "List branches with: bata db branches --json");
|
|
244
|
+
}
|
|
245
|
+
const targetName = name || defaultRestoreBranchName(sourceBranch.name);
|
|
246
|
+
s?.update(`Restoring ${colors.cyan(sourceBranch.name)} to ${colors.cyan(point.raw)}`);
|
|
247
|
+
const body = {
|
|
248
|
+
project_id: projectId,
|
|
249
|
+
source_branch_id: sourceBranch.id,
|
|
250
|
+
target_branch_name: targetName,
|
|
251
|
+
restore_point: point.kind === "lsn" ? { lsn: point.value } : { timestamp: point.value },
|
|
252
|
+
};
|
|
253
|
+
const res = await api.post("/v1/restore", body, token);
|
|
254
|
+
s?.stop();
|
|
255
|
+
if (!res.ok) {
|
|
256
|
+
const respBody = res.data;
|
|
257
|
+
if (isRetryable({ status: res.status, code: respBody?.code, message: respBody?.error })) {
|
|
258
|
+
emitError("API_UNAVAILABLE", apiError(res, "Restore failed"), "transient upstream error — retry in a few seconds");
|
|
259
|
+
}
|
|
260
|
+
const code = res.status === 401 || res.status === 403 ? "INVALID_KEY" : "CLI_ERROR";
|
|
261
|
+
emitError(code, apiError(res, "Restore failed"), "");
|
|
262
|
+
}
|
|
263
|
+
const restored = res.data.restored_branch;
|
|
264
|
+
if (!restored) {
|
|
265
|
+
emitError("CLI_ERROR", res.data.error || "Restore did not return a branch.", "");
|
|
266
|
+
}
|
|
267
|
+
const queryHint = `bata db query "SELECT 1" --branch ${restored.id}`;
|
|
268
|
+
if (jsonMode) {
|
|
269
|
+
json({
|
|
270
|
+
restored_branch: {
|
|
271
|
+
id: restored.id,
|
|
272
|
+
name: restored.name,
|
|
273
|
+
parent_branch_id: restored.parent_branch_id,
|
|
274
|
+
restore_point: restored.restore_point,
|
|
275
|
+
resolved_lsn: restored.resolved_lsn,
|
|
276
|
+
compute_id: restored.compute_id,
|
|
277
|
+
},
|
|
278
|
+
source_branch_id: sourceBranch.id,
|
|
279
|
+
operation_id: res.data.operation_id ?? null,
|
|
280
|
+
// A NEW branch — the source branch's data is untouched.
|
|
281
|
+
non_destructive: true,
|
|
282
|
+
// The branch row exists immediately; its compute may still be
|
|
283
|
+
// provisioning — poll before querying.
|
|
284
|
+
ready: false,
|
|
285
|
+
poll: "bata db branches --json",
|
|
286
|
+
query_hint: queryHint,
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
log();
|
|
291
|
+
success(`Restore initiated — created a NEW branch (no data on ${colors.cyan(sourceBranch.name)} was changed)`);
|
|
292
|
+
log();
|
|
293
|
+
kvList([
|
|
294
|
+
["New branch", `${colors.cyan(restored.name)} ${colors.dim(restored.id)}`],
|
|
295
|
+
["Restored from", `${sourceBranch.name} at ${point.raw} ${colors.dim(`(${point.kind})`)}`],
|
|
296
|
+
["Resolved LSN", restored.resolved_lsn],
|
|
297
|
+
["Operation", res.data.operation_id ?? "-"],
|
|
298
|
+
]);
|
|
299
|
+
log();
|
|
300
|
+
log(` ${colors.dim("Poll readiness with")} ${colors.cyan("bata db branches")}${colors.dim(", then query it:")}`);
|
|
301
|
+
log(` ${colors.cyan(queryHint)}`);
|
|
302
|
+
log();
|
|
303
|
+
}
|
|
304
|
+
// ─── Help + dispatch ─────────────────────────────────────────────────────────
|
|
305
|
+
function hasHelpFlag(args) {
|
|
306
|
+
return args.some((a) => a === "--help" || a === "-h");
|
|
307
|
+
}
|
|
308
|
+
function restoreHelp(sub) {
|
|
309
|
+
const usage = (line) => log(` ${colors.cyan(line)}`);
|
|
310
|
+
const note = (line) => log(` ${colors.dim(line)}`);
|
|
311
|
+
log();
|
|
312
|
+
switch (sub) {
|
|
313
|
+
case "points":
|
|
314
|
+
log(` ${colors.bold("bata restore points")} — list recovery points for a project`);
|
|
315
|
+
log();
|
|
316
|
+
usage("bata restore points [--project <id>] [--json]");
|
|
317
|
+
log();
|
|
318
|
+
note("Shows recorded recovery points, each branch's live LSN, and the PITR window.");
|
|
319
|
+
note("Points are metadata snapshots (~every 6h), not full backups — see below.");
|
|
320
|
+
break;
|
|
321
|
+
case "create":
|
|
322
|
+
log(` ${colors.bold("bata restore create")} — restore a branch to a point in time`);
|
|
323
|
+
log();
|
|
324
|
+
usage("bata restore create --branch <name-or-id> --at <ISO-timestamp|LSN> [--name <newBranch>] [--json]");
|
|
325
|
+
log();
|
|
326
|
+
note("Non-destructive: creates a NEW branch at the chosen point; the source");
|
|
327
|
+
note("branch's data is never overwritten. --at accepts an ISO-8601 timestamp");
|
|
328
|
+
note("(e.g. 2026-07-04T12:00:00Z) or an LSN (e.g. 0/15994B0).");
|
|
329
|
+
note("--name defaults to restore-<branch>-<timestamp> if omitted.");
|
|
330
|
+
break;
|
|
331
|
+
default:
|
|
332
|
+
log(` ${colors.bold("bata restore")} — point-in-time restore (PITR)`);
|
|
333
|
+
log();
|
|
334
|
+
usage("bata restore points List recovery points + the PITR window");
|
|
335
|
+
usage("bata restore create Restore a branch to a timestamp/LSN as a NEW branch");
|
|
336
|
+
log();
|
|
337
|
+
note("Restore is non-destructive — it always creates a new branch.");
|
|
338
|
+
note("Add --help to a subcommand for details (e.g. bata restore create --help).");
|
|
339
|
+
}
|
|
340
|
+
log();
|
|
341
|
+
}
|
|
342
|
+
export async function handleRestore(args) {
|
|
343
|
+
const sub = args[0];
|
|
344
|
+
if (hasHelpFlag(args) || !sub) {
|
|
345
|
+
restoreHelp(sub);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
switch (sub) {
|
|
349
|
+
case "points":
|
|
350
|
+
return restorePoints(args.slice(1));
|
|
351
|
+
case "create":
|
|
352
|
+
return restoreCreate(args.slice(1));
|
|
353
|
+
default:
|
|
354
|
+
emitError("INVALID_FLAG", `Unknown subcommand: restore ${sub}`, "Available: points, create");
|
|
355
|
+
}
|
|
356
|
+
}
|
package/dist/commands/schema.js
CHANGED
|
@@ -53,6 +53,16 @@ function parseCheckArgs(args) {
|
|
|
53
53
|
const riskColor = (r) => r === "breaking" ? colors.red(r.toUpperCase())
|
|
54
54
|
: r === "risky" || r === "unknown" ? colors.yellow(r.toUpperCase())
|
|
55
55
|
: colors.green(r.toUpperCase());
|
|
56
|
+
/** `model:action` when the shape came from the ORM corpus, else "—" (raw SQL). */
|
|
57
|
+
export function ormLabel(q) {
|
|
58
|
+
if (q.model && q.action)
|
|
59
|
+
return `${q.model}:${q.action}`;
|
|
60
|
+
if (q.model)
|
|
61
|
+
return q.model;
|
|
62
|
+
if (q.action)
|
|
63
|
+
return q.action;
|
|
64
|
+
return "—";
|
|
65
|
+
}
|
|
56
66
|
function affectedLabel(a) {
|
|
57
67
|
if (a.index)
|
|
58
68
|
return `index ${a.index}`;
|
|
@@ -145,8 +155,11 @@ function renderReport(v) {
|
|
|
145
155
|
log(` ${colors.dim(f.statement)}`);
|
|
146
156
|
if (f.impactedQueries.length) {
|
|
147
157
|
const shown = f.impactedQueries.slice(0, 8);
|
|
148
|
-
|
|
149
|
-
|
|
158
|
+
// Show model:action (from the ORM corpus) when present — the differentiator
|
|
159
|
+
// over raw pg_stat_statements shapes, which have no ORM semantics ("—").
|
|
160
|
+
table(["MODEL:ACTION", "QUERY", "CALLS", "MEAN", "RISK"], shown.map((q) => [
|
|
161
|
+
ormLabel(q),
|
|
162
|
+
q.query.length > 48 ? q.query.slice(0, 45) + "…" : q.query,
|
|
150
163
|
q.calls.toLocaleString(),
|
|
151
164
|
`${q.meanTimeMs}ms`,
|
|
152
165
|
q.risk,
|
package/dist/commands/usage.d.ts
CHANGED
|
@@ -11,3 +11,14 @@
|
|
|
11
11
|
* We will only show a dollar figure for a dimension we genuinely meter.
|
|
12
12
|
*/
|
|
13
13
|
export declare function usage(args: string[]): Promise<void>;
|
|
14
|
+
/**
|
|
15
|
+
* Format an estimated cost for human output. Cost-truth: `null` means "no
|
|
16
|
+
* compute metered", rendered as "not metered yet" — NEVER "$0.00" (that would
|
|
17
|
+
* read as a real priced zero). A sub-cent but non-zero estimate is "<$0.01" so
|
|
18
|
+
* it never collapses to $0.00; sub-dollar keeps 4dp to preserve fractional cents.
|
|
19
|
+
*/
|
|
20
|
+
export declare function estCost(cents: number | null): string;
|
|
21
|
+
export declare function modelActionLabel(q: {
|
|
22
|
+
model: string | null;
|
|
23
|
+
action: string | null;
|
|
24
|
+
}): string;
|
package/dist/commands/usage.js
CHANGED
|
@@ -14,6 +14,7 @@ import { api, apiError } from "../api.js";
|
|
|
14
14
|
import { requireToken, isJsonMode } from "../config.js";
|
|
15
15
|
import { colors, log, json, spinner, table, heading, kvList } from "../utils/logger.js";
|
|
16
16
|
import { emitError } from "../utils/errors.js";
|
|
17
|
+
import { resolveProjectId } from "../link.js";
|
|
17
18
|
/**
|
|
18
19
|
* Decide whether a dimension is truly metered. A dimension is metered only when
|
|
19
20
|
* the API explicitly says so (`metered === true`). A missing `metered` field is
|
|
@@ -44,15 +45,24 @@ function valueLabel(d) {
|
|
|
44
45
|
return `${d.value} ${d.unit}`;
|
|
45
46
|
}
|
|
46
47
|
function parseProjectFlag(args) {
|
|
48
|
+
return parseValueFlag(args, "--project");
|
|
49
|
+
}
|
|
50
|
+
/** Read `--name value` or `--name=value`; undefined if absent. */
|
|
51
|
+
function parseValueFlag(args, name) {
|
|
52
|
+
const eq = `${name}=`;
|
|
47
53
|
for (let i = 0; i < args.length; i++) {
|
|
48
|
-
if (args[i] ===
|
|
54
|
+
if (args[i] === name && args[i + 1])
|
|
49
55
|
return args[i + 1];
|
|
50
|
-
if (args[i].startsWith(
|
|
51
|
-
return args[i].slice(
|
|
56
|
+
if (args[i].startsWith(eq))
|
|
57
|
+
return args[i].slice(eq.length);
|
|
52
58
|
}
|
|
53
59
|
return undefined;
|
|
54
60
|
}
|
|
55
61
|
export async function usage(args) {
|
|
62
|
+
// `--by-query` is a per-project cost-attribution view (Lever L1): which ORM
|
|
63
|
+
// queries drive the project's compute bill. It's estimated, not metered.
|
|
64
|
+
if (args.includes("--by-query"))
|
|
65
|
+
return usageByQuery(args);
|
|
56
66
|
const jsonMode = isJsonMode();
|
|
57
67
|
const token = requireToken();
|
|
58
68
|
const projectFilter = parseProjectFlag(args);
|
|
@@ -162,3 +172,112 @@ export async function usage(args) {
|
|
|
162
172
|
log();
|
|
163
173
|
}
|
|
164
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Format an estimated cost for human output. Cost-truth: `null` means "no
|
|
177
|
+
* compute metered", rendered as "not metered yet" — NEVER "$0.00" (that would
|
|
178
|
+
* read as a real priced zero). A sub-cent but non-zero estimate is "<$0.01" so
|
|
179
|
+
* it never collapses to $0.00; sub-dollar keeps 4dp to preserve fractional cents.
|
|
180
|
+
*/
|
|
181
|
+
export function estCost(cents) {
|
|
182
|
+
if (cents === null)
|
|
183
|
+
return colors.dim("not metered yet");
|
|
184
|
+
if (cents <= 0)
|
|
185
|
+
return "$0.00";
|
|
186
|
+
const d = cents / 100;
|
|
187
|
+
if (d < 0.01)
|
|
188
|
+
return "<$0.01";
|
|
189
|
+
return `$${d.toFixed(d < 1 ? 4 : 2)}`;
|
|
190
|
+
}
|
|
191
|
+
export function modelActionLabel(q) {
|
|
192
|
+
if (q.model && q.action)
|
|
193
|
+
return `${q.model}.${q.action}`;
|
|
194
|
+
if (q.model)
|
|
195
|
+
return q.model;
|
|
196
|
+
if (q.action)
|
|
197
|
+
return q.action;
|
|
198
|
+
return colors.dim("raw");
|
|
199
|
+
}
|
|
200
|
+
async function usageByQuery(args) {
|
|
201
|
+
const jsonMode = isJsonMode();
|
|
202
|
+
const token = requireToken();
|
|
203
|
+
// Project precedence: --project flag > .batadata link > config default.
|
|
204
|
+
const { projectId } = resolveProjectId(parseProjectFlag(args));
|
|
205
|
+
if (!projectId) {
|
|
206
|
+
emitError("NO_PROJECT", "No project for --by-query.", "Pass --project <id>, or run `bata link <project>` to set a default.");
|
|
207
|
+
}
|
|
208
|
+
const timeRange = parseValueFlag(args, "--window") ?? "24h";
|
|
209
|
+
const branch = parseValueFlag(args, "--branch");
|
|
210
|
+
const query = { timeRange, orderBy: "totalTime", limit: "20" };
|
|
211
|
+
if (branch)
|
|
212
|
+
query.branch_id = branch;
|
|
213
|
+
const s = jsonMode ? null : spinner("Attributing compute cost by query");
|
|
214
|
+
const res = await api.get(`/v1/insights/${projectId}/turbine-queries`, token, query);
|
|
215
|
+
s?.stop();
|
|
216
|
+
if (!res.ok) {
|
|
217
|
+
emitError(res.status === 401 || res.status === 403 ? "INVALID_KEY"
|
|
218
|
+
: res.status === 404 ? "NO_PROJECT"
|
|
219
|
+
: res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
|
|
220
|
+
: "CLI_ERROR", apiError(res, "Failed to fetch per-query cost"), "");
|
|
221
|
+
}
|
|
222
|
+
const rows = res.data?.queries ?? [];
|
|
223
|
+
const basis = res.data?.costBasis ?? {
|
|
224
|
+
method: "no-compute-metered",
|
|
225
|
+
windowCuMs: 0,
|
|
226
|
+
windowComputeCents: null,
|
|
227
|
+
windowTotalTimeMs: 0,
|
|
228
|
+
};
|
|
229
|
+
const notMetered = basis.method === "no-compute-metered";
|
|
230
|
+
if (jsonMode) {
|
|
231
|
+
json({
|
|
232
|
+
project_id: projectId,
|
|
233
|
+
window: timeRange,
|
|
234
|
+
branch_id: branch ?? null,
|
|
235
|
+
// Carry the basis so a consumer can always see HOW the estimate was made
|
|
236
|
+
// and that it is off the billing path.
|
|
237
|
+
cost_basis: {
|
|
238
|
+
method: basis.method,
|
|
239
|
+
estimated: true,
|
|
240
|
+
window_cu_ms: basis.windowCuMs,
|
|
241
|
+
window_compute_cents: basis.windowComputeCents,
|
|
242
|
+
window_total_time_ms: basis.windowTotalTimeMs,
|
|
243
|
+
},
|
|
244
|
+
queries: rows.map((q) => ({
|
|
245
|
+
fingerprint: q.fingerprint,
|
|
246
|
+
model: q.model,
|
|
247
|
+
action: q.action,
|
|
248
|
+
sql_template: q.sqlTemplate,
|
|
249
|
+
calls: q.calls,
|
|
250
|
+
mean_time_ms: q.meanTimeMs,
|
|
251
|
+
time_share: q.costTimeShare,
|
|
252
|
+
// null (not 0) when nothing was metered — never a priced $0.
|
|
253
|
+
estimated_cost_cents: q.estimatedCostCents,
|
|
254
|
+
})),
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
heading("Cost by query — estimated");
|
|
259
|
+
log(` ${colors.dim(`Project ${projectId} · window ${timeRange}${branch ? ` · branch ${branch}` : ""}`)}`);
|
|
260
|
+
log();
|
|
261
|
+
if (rows.length === 0) {
|
|
262
|
+
log(` ${colors.dim("No ORM query traffic reported in this window.")}`);
|
|
263
|
+
log(` ${colors.dim("Wire up createInsightsReporter() to start attributing cost.")}`);
|
|
264
|
+
log();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
table(["QUERY", "CALLS", "MEAN", "EST. COST"], rows.map((q) => [
|
|
268
|
+
modelActionLabel(q),
|
|
269
|
+
q.calls.toLocaleString(),
|
|
270
|
+
`${q.meanTimeMs}ms`,
|
|
271
|
+
estCost(q.estimatedCostCents),
|
|
272
|
+
]));
|
|
273
|
+
log();
|
|
274
|
+
// Honest footer: never let the estimate masquerade as a metered charge.
|
|
275
|
+
if (notMetered) {
|
|
276
|
+
log(` ${colors.yellow("!")} ${colors.dim("No compute metered in this window — cost can't be attributed to queries yet (never shown as $0).")}`);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
log(` ${colors.bold("Window compute:")} ${estCost(basis.windowComputeCents)} ${colors.dim("total (metered)")}`);
|
|
280
|
+
log(` ${colors.dim("Per-query figures are ESTIMATES — Turbine insights are off the billing path; compute is apportioned by each query's share of ORM wall-time.")}`);
|
|
281
|
+
}
|
|
282
|
+
log();
|
|
283
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { create } from "./commands/create.js";
|
|
|
11
11
|
import { status } from "./commands/status.js";
|
|
12
12
|
import { connect } from "./commands/connect.js";
|
|
13
13
|
import { usage } from "./commands/usage.js";
|
|
14
|
+
import { handleRestore } from "./commands/restore.js";
|
|
14
15
|
import { link, unlink } from "./commands/link.js";
|
|
15
16
|
import { parseGlobalFlags } from "./args.js";
|
|
16
17
|
import { isJsonMode } from "./config.js";
|
|
@@ -53,6 +54,10 @@ function help() {
|
|
|
53
54
|
log(` ${colors.cyan("db studio")} Open table browser in browser`);
|
|
54
55
|
log(` ${colors.cyan("db query")} Run a SQL query ${colors.dim("(--branch <id> to target a branch)")}`);
|
|
55
56
|
log();
|
|
57
|
+
log(` ${colors.bold("Restore (PITR)")}`);
|
|
58
|
+
log(` ${colors.cyan("restore points")} List recovery points + the PITR window`);
|
|
59
|
+
log(` ${colors.cyan("restore create")} Restore a branch to a timestamp/LSN ${colors.dim("(creates a NEW branch)")}`);
|
|
60
|
+
log();
|
|
56
61
|
log(` ${colors.bold("Schema & Types")}`);
|
|
57
62
|
log(` ${colors.cyan("generate")} Generate types from database schema`);
|
|
58
63
|
log(` ${colors.cyan("generate --watch")} Watch mode for type generation`);
|
|
@@ -78,6 +83,7 @@ function help() {
|
|
|
78
83
|
log(` ${colors.cyan("db url --json")} Print connection string as JSON`);
|
|
79
84
|
log(` ${colors.cyan("db query <sql> --json")} Run SQL headlessly, rows as JSON objects`);
|
|
80
85
|
log(` ${colors.cyan("usage --json")} Per-dimension cost (honest: un-metered → null)`);
|
|
86
|
+
log(` ${colors.cyan("usage --by-query [--project <id>] [--json]")} Estimated compute cost per ORM query (off billing path)`);
|
|
81
87
|
log();
|
|
82
88
|
log(` ${colors.dim("All errors in --json mode share one envelope:")} ${colors.dim('{ "error", "code", "hint" }')} ${colors.dim("on stderr.")}`);
|
|
83
89
|
log();
|
|
@@ -159,6 +165,10 @@ async function main() {
|
|
|
159
165
|
case "db":
|
|
160
166
|
await handleDb(rest);
|
|
161
167
|
break;
|
|
168
|
+
// Restore (PITR)
|
|
169
|
+
case "restore":
|
|
170
|
+
await handleRestore(rest);
|
|
171
|
+
break;
|
|
162
172
|
// Schema
|
|
163
173
|
case "schema":
|
|
164
174
|
await handleSchema(rest);
|