@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 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
  };
@@ -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
@@ -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
- emitError("INVALID_FLAG", `Unknown: db branch ${action || ""}`, "Available: create, delete, checkout");
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@batadata/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "CLI for BataDB — serverless Postgres platform",
5
5
  "bin": {
6
6
  "bata": "./dist/index.js"