@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 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
@@ -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 a `--branch <ref>` / `--branch=<ref>` flag out of the query args and
440
- * return the explicit branch ref an id OR a name, resolved later — plus the
441
- * remaining args, which join into the SQL string. Keeps `db query`
442
- * order-independent (the flag can sit before or after the SQL) and consistent
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 parseBranchFlag(args) {
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 } = parseBranchFlag(args);
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 may answer with exit 6");
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
- emitError("INVALID_FLAG", `Unknown: db branch ${action || ""}`, "Available: create, delete, checkout");
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
+ }
@@ -1 +1,6 @@
1
+ /** `model:action` when the shape came from the ORM corpus, else "—" (raw SQL). */
2
+ export declare function ormLabel(q: {
3
+ model?: string | null;
4
+ action?: string | null;
5
+ }): string;
1
6
  export declare function handleSchema(args: string[]): Promise<void>;
@@ -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
- table(["QUERY", "CALLS", "MEAN", "RISK"], shown.map((q) => [
149
- q.query.length > 60 ? q.query.slice(0, 57) + "" : q.query,
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,
@@ -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;
@@ -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] === "--project" && args[i + 1])
54
+ if (args[i] === name && args[i + 1])
49
55
  return args[i + 1];
50
- if (args[i].startsWith("--project="))
51
- return args[i].slice("--project=".length);
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@batadata/cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "CLI for BataDB — serverless Postgres platform",
5
5
  "bin": {
6
6
  "bata": "./dist/index.js"