@batadata/cli 0.1.8 → 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 +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 +67 -2
- package/dist/commands/restore.d.ts +71 -0
- package/dist/commands/restore.js +356 -0
- package/dist/index.js +9 -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
|
@@ -219,6 +219,8 @@ export async function branches() {
|
|
|
219
219
|
// Ephemeral-branch TTL: when set, the branch is auto-reaped after this.
|
|
220
220
|
expires_at: b.expiresAt ?? null,
|
|
221
221
|
purpose: b.purpose ?? null,
|
|
222
|
+
// Protection flag — a protected branch refuses delete/reset/rollback/reap.
|
|
223
|
+
protected: b.isProtected ?? false,
|
|
222
224
|
created_at: b.createdAt ?? null,
|
|
223
225
|
})),
|
|
224
226
|
count: branchList.length,
|
|
@@ -231,9 +233,12 @@ export async function branches() {
|
|
|
231
233
|
log();
|
|
232
234
|
return;
|
|
233
235
|
}
|
|
234
|
-
table(["NAME", "PRIMARY", "STATUS", "EXPIRES", "CREATED"], branchList.map((b) => [
|
|
236
|
+
table(["NAME", "PRIMARY", "PROTECTED", "STATUS", "EXPIRES", "CREATED"], branchList.map((b) => [
|
|
235
237
|
b.name,
|
|
236
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") : "-",
|
|
237
242
|
// Live compute lifecycle (computeStatus), with a check once ready so the
|
|
238
243
|
// column is scannable.
|
|
239
244
|
branchStatusLabel(b),
|
|
@@ -359,6 +364,56 @@ export async function branchDelete(name) {
|
|
|
359
364
|
log(` ${colors.green(">")} Branch ${colors.cyan(name)} deleted`);
|
|
360
365
|
log();
|
|
361
366
|
}
|
|
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();
|
|
376
|
+
const config = loadConfig();
|
|
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
|
+
}
|
|
362
417
|
/**
|
|
363
418
|
* Pull a `--project <id>` / `--project=<id>` flag out of args, returning the
|
|
364
419
|
* explicit project id (if any) and the remaining positionals. Lets `db branch
|
|
@@ -598,11 +653,15 @@ function dbHelp(sub) {
|
|
|
598
653
|
usage("bata db branch create <name> [--expires-in <2h|30m|7d>] [--purpose <text>]");
|
|
599
654
|
usage("bata db branch delete <name> [--yes]");
|
|
600
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>");
|
|
601
658
|
log();
|
|
602
659
|
note("--expires-in Auto-delete the branch after this long (units: s/m/h/d/w).");
|
|
603
660
|
note("--purpose Free-text note describing why the branch exists.");
|
|
604
661
|
note("checkout pins the branch into .batadata/project.json so db query/url");
|
|
605
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).");
|
|
606
665
|
break;
|
|
607
666
|
case "url":
|
|
608
667
|
log(` ${colors.bold("bata db url")} — print the connection string`);
|
|
@@ -629,6 +688,8 @@ function dbHelp(sub) {
|
|
|
629
688
|
usage("bata db branch create Create a new branch");
|
|
630
689
|
usage("bata db branch delete Delete a branch");
|
|
631
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");
|
|
632
693
|
usage("bata db studio Open the table browser");
|
|
633
694
|
usage("bata db query <sql> Run a SQL query (--branch <id-or-name> to target a branch)");
|
|
634
695
|
log();
|
|
@@ -660,7 +721,11 @@ export async function handleDb(args) {
|
|
|
660
721
|
return branchDelete(args[2]);
|
|
661
722
|
if (action === "checkout")
|
|
662
723
|
return branchCheckout(args.slice(2));
|
|
663
|
-
|
|
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");
|
|
664
729
|
}
|
|
665
730
|
case "studio":
|
|
666
731
|
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/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`);
|
|
@@ -159,6 +164,10 @@ async function main() {
|
|
|
159
164
|
case "db":
|
|
160
165
|
await handleDb(rest);
|
|
161
166
|
break;
|
|
167
|
+
// Restore (PITR)
|
|
168
|
+
case "restore":
|
|
169
|
+
await handleRestore(rest);
|
|
170
|
+
break;
|
|
162
171
|
// Schema
|
|
163
172
|
case "schema":
|
|
164
173
|
await handleSchema(rest);
|