@batadata/cli 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/commands/connect.js +4 -2
- package/dist/commands/db.d.ts +35 -1
- package/dist/commands/db.js +242 -17
- package/dist/commands/link.d.ts +2 -0
- package/dist/commands/link.js +138 -0
- package/dist/commands/projects.js +6 -4
- package/dist/commands/restore.d.ts +71 -0
- package/dist/commands/restore.js +356 -0
- package/dist/commands/schema.js +5 -4
- package/dist/index.js +19 -0
- package/dist/link.d.ts +107 -0
- package/dist/link.js +170 -0
- package/package.json +1 -1
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { api, apiError, resolveTeamId, asList } from "../api.js";
|
|
2
|
+
import { requireToken, isJsonMode } from "../config.js";
|
|
3
|
+
import { colors, log, json, success, spinner } from "../utils/logger.js";
|
|
4
|
+
import { emitError } from "../utils/errors.js";
|
|
5
|
+
import { select } from "../utils/prompts.js";
|
|
6
|
+
import { LINK_DIR, findLinkFile, readLinkFile, writeLinkFile, removeLinkFile, } from "../link.js";
|
|
7
|
+
/**
|
|
8
|
+
* Pull `--status` out of the args and return the remaining positionals. Global
|
|
9
|
+
* flags (`--json` etc.) are stripped upstream by parseGlobalFlags, so anything
|
|
10
|
+
* still `--`-prefixed here is a link-specific flag.
|
|
11
|
+
*/
|
|
12
|
+
function parseLinkArgs(args) {
|
|
13
|
+
let status = false;
|
|
14
|
+
let positional;
|
|
15
|
+
for (const arg of args) {
|
|
16
|
+
if (arg === "--status")
|
|
17
|
+
status = true;
|
|
18
|
+
else if (!arg.startsWith("-") && positional === undefined)
|
|
19
|
+
positional = arg;
|
|
20
|
+
}
|
|
21
|
+
return { status, positional };
|
|
22
|
+
}
|
|
23
|
+
/** Fetch the caller's projects (team-scoped, exactly like `projects list`). */
|
|
24
|
+
async function fetchProjects(token) {
|
|
25
|
+
const teamId = await resolveTeamId(token);
|
|
26
|
+
if (!teamId) {
|
|
27
|
+
emitError("NO_TEAM", "No team found for this credential.", "This API key isn't attached to a team. Run `bata login` or check `bata whoami`.");
|
|
28
|
+
}
|
|
29
|
+
const res = await api.get("/v1/projects", token, { team_id: teamId });
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
emitError(res.status === 401 || res.status === 403 ? "INVALID_KEY"
|
|
32
|
+
: res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
|
|
33
|
+
: "CLI_ERROR", apiError(res, "Failed to fetch projects"), "");
|
|
34
|
+
}
|
|
35
|
+
return asList(res.data);
|
|
36
|
+
}
|
|
37
|
+
/** Show what (if anything) the current directory resolves to. */
|
|
38
|
+
function linkStatus() {
|
|
39
|
+
const found = readLinkFile();
|
|
40
|
+
const jsonMode = isJsonMode();
|
|
41
|
+
if (!found) {
|
|
42
|
+
if (jsonMode) {
|
|
43
|
+
json({ linked: false, project_id: null, branch_id: null, link_file: null });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
log();
|
|
47
|
+
log(` ${colors.dim("No project linked in this directory.")}`);
|
|
48
|
+
log(` Run ${colors.cyan("bata link <project>")} to link one.`);
|
|
49
|
+
log();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const { path: linkFile, link } = found;
|
|
53
|
+
if (jsonMode) {
|
|
54
|
+
json({
|
|
55
|
+
linked: true,
|
|
56
|
+
project_id: link.projectId,
|
|
57
|
+
branch_id: link.branchId ?? null,
|
|
58
|
+
link_file: linkFile,
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
log();
|
|
63
|
+
success(`Linked to project ${colors.cyan(link.projectId)}`);
|
|
64
|
+
log(` ${colors.dim("Branch:")} ${link.branchId ? colors.cyan(link.branchId) : colors.dim("(none)")}`);
|
|
65
|
+
log(` ${colors.dim("Link file:")} ${colors.dim(linkFile)}`);
|
|
66
|
+
log();
|
|
67
|
+
}
|
|
68
|
+
export async function link(args) {
|
|
69
|
+
const { status, positional } = parseLinkArgs(args);
|
|
70
|
+
const jsonMode = isJsonMode();
|
|
71
|
+
if (status) {
|
|
72
|
+
linkStatus();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// Input-contract check first (no token needed): headless with no project is a
|
|
76
|
+
// hard MISSING_ARG — never guess a project for an agent, never hang on a
|
|
77
|
+
// prompt. Doing this before requireToken() gives the clearer error.
|
|
78
|
+
if (!positional && (jsonMode || !process.stdin.isTTY)) {
|
|
79
|
+
emitError("MISSING_ARG", "No project specified.", "Pass a project id or name: bata link <project> (interactive selection needs a TTY).");
|
|
80
|
+
}
|
|
81
|
+
const token = requireToken();
|
|
82
|
+
let target;
|
|
83
|
+
if (positional) {
|
|
84
|
+
// Resolve by id OR name — the same string a user passes to other commands
|
|
85
|
+
// (mirrors how connect.ts and db.ts resolve --branch by name|id).
|
|
86
|
+
const s = jsonMode ? null : spinner("Resolving project");
|
|
87
|
+
const projects = await fetchProjects(token);
|
|
88
|
+
s?.stop();
|
|
89
|
+
target = projects.find((p) => p.id === positional || p.name === positional);
|
|
90
|
+
if (!target) {
|
|
91
|
+
emitError("NOT_FOUND", `Project "${positional}" not found.`, "List your projects with: bata projects list --json");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
// No argument in an interactive TTY → pick from a list.
|
|
96
|
+
const projects = await fetchProjects(token);
|
|
97
|
+
if (projects.length === 0) {
|
|
98
|
+
emitError("NOT_FOUND", "You have no projects to link.", "Create one with: bata create <name>");
|
|
99
|
+
}
|
|
100
|
+
const chosen = await select("Select a project to link", projects.map((p) => ({ label: `${p.name} ${colors.dim(p.id)}`, value: p.id })));
|
|
101
|
+
target = projects.find((p) => p.id === chosen);
|
|
102
|
+
}
|
|
103
|
+
const linkFile = writeLinkFile(process.cwd(), { projectId: target.id, branchId: null });
|
|
104
|
+
if (jsonMode) {
|
|
105
|
+
json({
|
|
106
|
+
linked: true,
|
|
107
|
+
project_id: target.id,
|
|
108
|
+
project_name: target.name,
|
|
109
|
+
branch_id: null,
|
|
110
|
+
link_file: linkFile,
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
log();
|
|
115
|
+
success(`Linked to ${colors.cyan(target.name)} ${colors.dim(target.id)}`);
|
|
116
|
+
log(` ${colors.dim("Wrote")} ${colors.dim(linkFile)}`);
|
|
117
|
+
log();
|
|
118
|
+
log(` ${colors.dim("Commands in this directory now target this project — no")} ${colors.cyan("--project")} ${colors.dim("needed.")}`);
|
|
119
|
+
log(` ${colors.dim("Tip: add")} ${colors.cyan(`${LINK_DIR}/`)} ${colors.dim("to your .gitignore.")}`);
|
|
120
|
+
log();
|
|
121
|
+
}
|
|
122
|
+
export function unlink() {
|
|
123
|
+
const jsonMode = isJsonMode();
|
|
124
|
+
const existed = findLinkFile();
|
|
125
|
+
const removed = removeLinkFile();
|
|
126
|
+
if (jsonMode) {
|
|
127
|
+
json({ unlinked: Boolean(existed), link_file: removed });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
log();
|
|
131
|
+
if (removed) {
|
|
132
|
+
success(`Unlinked — removed ${colors.dim(removed)}`);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
log(` ${colors.dim("No project link found in this directory.")}`);
|
|
136
|
+
}
|
|
137
|
+
log();
|
|
138
|
+
}
|
|
@@ -3,6 +3,7 @@ import { requireToken, loadConfig, saveConfig, isJsonMode } from "../config.js";
|
|
|
3
3
|
import { colors, log, json, success, spinner, table, kvList, heading } from "../utils/logger.js";
|
|
4
4
|
import { prompt, confirmDestructive, select } from "../utils/prompts.js";
|
|
5
5
|
import { emitError } from "../utils/errors.js";
|
|
6
|
+
import { resolveProjectId } from "../link.js";
|
|
6
7
|
function projectCreatedAt(p) {
|
|
7
8
|
return p.created_at ?? p.createdAt ?? "";
|
|
8
9
|
}
|
|
@@ -134,11 +135,11 @@ export async function create() {
|
|
|
134
135
|
}
|
|
135
136
|
export async function info(projectId) {
|
|
136
137
|
const token = requireToken();
|
|
137
|
-
const config = loadConfig();
|
|
138
138
|
const jsonMode = isJsonMode();
|
|
139
|
-
|
|
139
|
+
// Precedence: explicit arg/--project > .batadata link > config default.
|
|
140
|
+
const id = resolveProjectId(projectId).projectId;
|
|
140
141
|
if (!id) {
|
|
141
|
-
emitError("NO_PROJECT", "No project specified.", "Pass a project ID or set a default with bata projects create.");
|
|
142
|
+
emitError("NO_PROJECT", "No project specified.", "Pass a project ID, run `bata link <project>`, or set a default with bata projects create.");
|
|
142
143
|
}
|
|
143
144
|
const s = jsonMode ? null : spinner("Fetching project details");
|
|
144
145
|
const teamId = await resolveTeamId(token);
|
|
@@ -213,7 +214,8 @@ export async function deleteProject(projectId) {
|
|
|
213
214
|
const jsonMode = isJsonMode();
|
|
214
215
|
const token = requireToken();
|
|
215
216
|
const config = loadConfig();
|
|
216
|
-
|
|
217
|
+
// Precedence: explicit arg/--project > .batadata link > config default.
|
|
218
|
+
const id = resolveProjectId(projectId).projectId;
|
|
217
219
|
if (!id) {
|
|
218
220
|
emitError("NO_PROJECT", "No project specified.", "Pass a project ID: bata projects delete <id>");
|
|
219
221
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bata restore — Point-in-time restore (PITR).
|
|
3
|
+
*
|
|
4
|
+
* Two subcommands:
|
|
5
|
+
* restore points [--project <id>] [--json]
|
|
6
|
+
* List the recovery points recorded for a project (GET
|
|
7
|
+
* /v1/recovery-points/:projectId) plus each branch's live LSN and the
|
|
8
|
+
* honest PITR window.
|
|
9
|
+
*
|
|
10
|
+
* restore create --branch <name-or-id> --at <ISO-timestamp|LSN>
|
|
11
|
+
* [--name <newBranchName>] [--project <id>] [--json]
|
|
12
|
+
* Restore a branch to a point in time by creating a NEW branch at that
|
|
13
|
+
* point (POST /v1/restore). This is non-destructive: the source branch's
|
|
14
|
+
* data is never overwritten.
|
|
15
|
+
*
|
|
16
|
+
* HONESTY RULE (documented, never overclaimed):
|
|
17
|
+
* Recovery points are timeline-metadata snapshots the backup scheduler records
|
|
18
|
+
* about every 6 hours (BACKUP_INTERVAL_HOURS) — they are NOT full physical
|
|
19
|
+
* backups, and the earliest listed point is the oldest metadata row we have,
|
|
20
|
+
* not a guaranteed floor on how far back you can restore. Restorability to an
|
|
21
|
+
* arbitrary timestamp depends on how much WAL the pageserver still retains; if
|
|
22
|
+
* the WAL doesn't extend that far the server returns "No data available at the
|
|
23
|
+
* requested timestamp." We surface that verbatim rather than pretending the
|
|
24
|
+
* window is deeper than it is.
|
|
25
|
+
*/
|
|
26
|
+
export interface RestorePoint {
|
|
27
|
+
/** How the input was interpreted. */
|
|
28
|
+
kind: "lsn" | "timestamp";
|
|
29
|
+
/**
|
|
30
|
+
* The value to send to the server. For an LSN it's the input as-is; for a
|
|
31
|
+
* timestamp it's normalized to offset-aware ISO 8601 so the server's
|
|
32
|
+
* `datetime({ offset: true })` validation accepts it.
|
|
33
|
+
*/
|
|
34
|
+
value: string;
|
|
35
|
+
/** The raw user input (for human display). */
|
|
36
|
+
raw: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Classify a `--at` value as an LSN or a timestamp. An LSN (`0/15994B0`) is
|
|
40
|
+
* matched structurally; anything else is parsed as a date and normalized to
|
|
41
|
+
* offset-aware ISO 8601. Returns `{ error }` for input that is neither.
|
|
42
|
+
* Exported for unit testing.
|
|
43
|
+
*/
|
|
44
|
+
export declare function classifyRestorePoint(input: string): {
|
|
45
|
+
point?: RestorePoint;
|
|
46
|
+
error?: string;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Parse `restore create` args: `--branch`, `--at`, `--name`, `--project`
|
|
50
|
+
* (each accepting both `--flag value` and `--flag=value`). Order-independent,
|
|
51
|
+
* mirroring `parseBranchCreateArgs`. Exported for unit testing.
|
|
52
|
+
*/
|
|
53
|
+
export declare function parseRestoreCreateArgs(args: string[]): {
|
|
54
|
+
branch?: string;
|
|
55
|
+
at?: string;
|
|
56
|
+
name?: string;
|
|
57
|
+
projectId?: string;
|
|
58
|
+
};
|
|
59
|
+
/** Parse `restore points` args — only `--project` is meaningful here. */
|
|
60
|
+
export declare function parseRestorePointsArgs(args: string[]): {
|
|
61
|
+
projectId?: string;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* A default name for the restored branch when `--name` is omitted. Includes a
|
|
65
|
+
* compact UTC stamp so repeated restores don't collide, e.g.
|
|
66
|
+
* `restore-main-20260704T120000Z`.
|
|
67
|
+
*/
|
|
68
|
+
export declare function defaultRestoreBranchName(sourceName: string, now?: Date): string;
|
|
69
|
+
export declare function restorePoints(args: string[]): Promise<void>;
|
|
70
|
+
export declare function restoreCreate(args: string[]): Promise<void>;
|
|
71
|
+
export declare function handleRestore(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bata restore — Point-in-time restore (PITR).
|
|
3
|
+
*
|
|
4
|
+
* Two subcommands:
|
|
5
|
+
* restore points [--project <id>] [--json]
|
|
6
|
+
* List the recovery points recorded for a project (GET
|
|
7
|
+
* /v1/recovery-points/:projectId) plus each branch's live LSN and the
|
|
8
|
+
* honest PITR window.
|
|
9
|
+
*
|
|
10
|
+
* restore create --branch <name-or-id> --at <ISO-timestamp|LSN>
|
|
11
|
+
* [--name <newBranchName>] [--project <id>] [--json]
|
|
12
|
+
* Restore a branch to a point in time by creating a NEW branch at that
|
|
13
|
+
* point (POST /v1/restore). This is non-destructive: the source branch's
|
|
14
|
+
* data is never overwritten.
|
|
15
|
+
*
|
|
16
|
+
* HONESTY RULE (documented, never overclaimed):
|
|
17
|
+
* Recovery points are timeline-metadata snapshots the backup scheduler records
|
|
18
|
+
* about every 6 hours (BACKUP_INTERVAL_HOURS) — they are NOT full physical
|
|
19
|
+
* backups, and the earliest listed point is the oldest metadata row we have,
|
|
20
|
+
* not a guaranteed floor on how far back you can restore. Restorability to an
|
|
21
|
+
* arbitrary timestamp depends on how much WAL the pageserver still retains; if
|
|
22
|
+
* the WAL doesn't extend that far the server returns "No data available at the
|
|
23
|
+
* requested timestamp." We surface that verbatim rather than pretending the
|
|
24
|
+
* window is deeper than it is.
|
|
25
|
+
*/
|
|
26
|
+
import { api, apiError } from "../api.js";
|
|
27
|
+
import { requireToken, loadConfig, isJsonMode } from "../config.js";
|
|
28
|
+
import { colors, log, json, spinner, table, heading, kvList, success } from "../utils/logger.js";
|
|
29
|
+
import { emitError, isRetryable } from "../utils/errors.js";
|
|
30
|
+
import { resolveProjectId } from "../link.js";
|
|
31
|
+
// A Postgres LSN is two hex words joined by a slash, e.g. `0/15994B0`. That
|
|
32
|
+
// shape can never collide with an ISO-8601 timestamp (which carries `-`/`:`),
|
|
33
|
+
// so the slash-of-hex is a safe discriminator.
|
|
34
|
+
const LSN_RE = /^[0-9A-Fa-f]+\/[0-9A-Fa-f]+$/;
|
|
35
|
+
/**
|
|
36
|
+
* Classify a `--at` value as an LSN or a timestamp. An LSN (`0/15994B0`) is
|
|
37
|
+
* matched structurally; anything else is parsed as a date and normalized to
|
|
38
|
+
* offset-aware ISO 8601. Returns `{ error }` for input that is neither.
|
|
39
|
+
* Exported for unit testing.
|
|
40
|
+
*/
|
|
41
|
+
export function classifyRestorePoint(input) {
|
|
42
|
+
const raw = (input ?? "").trim();
|
|
43
|
+
if (!raw) {
|
|
44
|
+
return { error: "A restore point is required. Pass --at <ISO-timestamp|LSN>." };
|
|
45
|
+
}
|
|
46
|
+
if (LSN_RE.test(raw)) {
|
|
47
|
+
return { point: { kind: "lsn", value: raw, raw } };
|
|
48
|
+
}
|
|
49
|
+
const d = new Date(raw);
|
|
50
|
+
if (!Number.isNaN(d.getTime())) {
|
|
51
|
+
return { point: { kind: "timestamp", value: d.toISOString(), raw } };
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
error: `Could not parse "${raw}" as an ISO-8601 timestamp or an LSN (e.g. 0/15994B0).`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Parse `restore create` args: `--branch`, `--at`, `--name`, `--project`
|
|
59
|
+
* (each accepting both `--flag value` and `--flag=value`). Order-independent,
|
|
60
|
+
* mirroring `parseBranchCreateArgs`. Exported for unit testing.
|
|
61
|
+
*/
|
|
62
|
+
export function parseRestoreCreateArgs(args) {
|
|
63
|
+
let branch;
|
|
64
|
+
let at;
|
|
65
|
+
let name;
|
|
66
|
+
let projectId;
|
|
67
|
+
for (let i = 0; i < args.length; i++) {
|
|
68
|
+
const arg = args[i];
|
|
69
|
+
if (arg === "--branch")
|
|
70
|
+
branch = args[++i];
|
|
71
|
+
else if (arg.startsWith("--branch="))
|
|
72
|
+
branch = arg.slice("--branch=".length);
|
|
73
|
+
else if (arg === "--at")
|
|
74
|
+
at = args[++i];
|
|
75
|
+
else if (arg.startsWith("--at="))
|
|
76
|
+
at = arg.slice("--at=".length);
|
|
77
|
+
else if (arg === "--name")
|
|
78
|
+
name = args[++i];
|
|
79
|
+
else if (arg.startsWith("--name="))
|
|
80
|
+
name = arg.slice("--name=".length);
|
|
81
|
+
else if (arg === "--project")
|
|
82
|
+
projectId = args[++i];
|
|
83
|
+
else if (arg.startsWith("--project="))
|
|
84
|
+
projectId = arg.slice("--project=".length);
|
|
85
|
+
}
|
|
86
|
+
return { branch, at, name, projectId };
|
|
87
|
+
}
|
|
88
|
+
/** Parse `restore points` args — only `--project` is meaningful here. */
|
|
89
|
+
export function parseRestorePointsArgs(args) {
|
|
90
|
+
let projectId;
|
|
91
|
+
for (let i = 0; i < args.length; i++) {
|
|
92
|
+
const arg = args[i];
|
|
93
|
+
if (arg === "--project")
|
|
94
|
+
projectId = args[++i];
|
|
95
|
+
else if (arg.startsWith("--project="))
|
|
96
|
+
projectId = arg.slice("--project=".length);
|
|
97
|
+
}
|
|
98
|
+
return { projectId };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* A default name for the restored branch when `--name` is omitted. Includes a
|
|
102
|
+
* compact UTC stamp so repeated restores don't collide, e.g.
|
|
103
|
+
* `restore-main-20260704T120000Z`.
|
|
104
|
+
*/
|
|
105
|
+
export function defaultRestoreBranchName(sourceName, now = new Date()) {
|
|
106
|
+
const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
|
|
107
|
+
return `restore-${sourceName}-${stamp}`;
|
|
108
|
+
}
|
|
109
|
+
// ─── Shared helpers ────────────────────────────────────────────────────────
|
|
110
|
+
function requireProject(projectFlag) {
|
|
111
|
+
const { projectId } = resolveProjectId(projectFlag);
|
|
112
|
+
if (!projectId) {
|
|
113
|
+
emitError("NO_PROJECT", "No project. Pass --project <id>, or link one with `bata link <project>`.", "Run `bata link <project>` once, then restore commands need no --project.");
|
|
114
|
+
}
|
|
115
|
+
return projectId;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Resolve a `--branch` reference (id OR name) to a concrete branch via
|
|
119
|
+
* GET /v1/projects/:id. Agents pass the same name they branched with, so
|
|
120
|
+
* accepting only ids would be a footgun — mirrors `db query --branch`.
|
|
121
|
+
*/
|
|
122
|
+
async function resolveBranchRef(projectId, token, teamId, ref) {
|
|
123
|
+
const query = {};
|
|
124
|
+
if (teamId)
|
|
125
|
+
query.team_id = teamId;
|
|
126
|
+
const res = await api.get(`/v1/projects/${projectId}`, token, query);
|
|
127
|
+
if (!res.ok)
|
|
128
|
+
return null;
|
|
129
|
+
const branches = res.data.branches ?? [];
|
|
130
|
+
return branches.find((b) => b.id === ref || b.name === ref) ?? null;
|
|
131
|
+
}
|
|
132
|
+
function formatDateTime(iso) {
|
|
133
|
+
if (!iso)
|
|
134
|
+
return "-";
|
|
135
|
+
const d = new Date(iso);
|
|
136
|
+
if (Number.isNaN(d.getTime()))
|
|
137
|
+
return iso;
|
|
138
|
+
return d.toLocaleString("en-US", {
|
|
139
|
+
month: "short",
|
|
140
|
+
day: "numeric",
|
|
141
|
+
year: "numeric",
|
|
142
|
+
hour: "2-digit",
|
|
143
|
+
minute: "2-digit",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
function formatBytes(bytes) {
|
|
147
|
+
if (bytes === null || bytes === undefined)
|
|
148
|
+
return "-";
|
|
149
|
+
if (bytes < 1024)
|
|
150
|
+
return `${bytes} B`;
|
|
151
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
152
|
+
let value = bytes / 1024;
|
|
153
|
+
let unit = 0;
|
|
154
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
155
|
+
value /= 1024;
|
|
156
|
+
unit++;
|
|
157
|
+
}
|
|
158
|
+
return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
|
|
159
|
+
}
|
|
160
|
+
// ─── restore points ────────────────────────────────────────────────────────
|
|
161
|
+
export async function restorePoints(args) {
|
|
162
|
+
const jsonMode = isJsonMode();
|
|
163
|
+
const token = requireToken();
|
|
164
|
+
const config = loadConfig();
|
|
165
|
+
const { projectId: projectFlag } = parseRestorePointsArgs(args);
|
|
166
|
+
const projectId = requireProject(projectFlag);
|
|
167
|
+
const query = {};
|
|
168
|
+
if (config.defaultTeam)
|
|
169
|
+
query.team_id = config.defaultTeam;
|
|
170
|
+
const s = jsonMode ? null : spinner("Fetching recovery points");
|
|
171
|
+
const res = await api.get(`/v1/recovery-points/${projectId}`, token, query);
|
|
172
|
+
s?.stop();
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
emitError(res.status === 401 || res.status === 403 ? "INVALID_KEY"
|
|
175
|
+
: res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
|
|
176
|
+
: "CLI_ERROR", apiError(res, "Failed to fetch recovery points"), "");
|
|
177
|
+
}
|
|
178
|
+
const data = res.data;
|
|
179
|
+
const points = data.recovery_points ?? [];
|
|
180
|
+
const live = data.live_state ?? [];
|
|
181
|
+
const window = data.pitr_window;
|
|
182
|
+
if (jsonMode) {
|
|
183
|
+
json({
|
|
184
|
+
project_id: data.project_id ?? projectId,
|
|
185
|
+
recovery_points: points,
|
|
186
|
+
live_state: live,
|
|
187
|
+
pitr_window: window,
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Map branch_id → name from live_state so the table shows names, not ids.
|
|
192
|
+
const nameById = new Map(live.map((b) => [b.branch_id, b.branch_name]));
|
|
193
|
+
heading(`Recovery points — ${projectId}`);
|
|
194
|
+
kvList([
|
|
195
|
+
["Earliest recorded", formatDateTime(window?.earliest_available ?? null)],
|
|
196
|
+
["Latest (live)", formatDateTime(window?.latest_available ?? null)],
|
|
197
|
+
]);
|
|
198
|
+
log();
|
|
199
|
+
if (points.length === 0) {
|
|
200
|
+
log(` ${colors.dim("No recorded recovery points yet.")}`);
|
|
201
|
+
log(` ${colors.dim("The backup scheduler records a point about every 6 hours.")}`);
|
|
202
|
+
log();
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
table(["RECORDED", "BRANCH", "LSN", "SIZE", "TYPE"], points.map((p) => [
|
|
206
|
+
formatDateTime(p.recorded_at),
|
|
207
|
+
nameById.get(p.branch_id) ?? p.branch_id,
|
|
208
|
+
p.lsn ?? "-",
|
|
209
|
+
formatBytes(p.size_bytes),
|
|
210
|
+
p.type,
|
|
211
|
+
]));
|
|
212
|
+
log();
|
|
213
|
+
}
|
|
214
|
+
// Honest window note — never overclaim depth. A recorded point is a metadata
|
|
215
|
+
// snapshot, and restorability past it depends on WAL retention.
|
|
216
|
+
log(` ${colors.dim("Recovery points are timeline-metadata snapshots (recorded ~every 6h),")}`);
|
|
217
|
+
log(` ${colors.dim("not full backups. Restore to any timestamp the WAL still covers with:")}`);
|
|
218
|
+
log(` ${colors.cyan('bata restore create --branch <name> --at <ISO-timestamp|LSN>')}`);
|
|
219
|
+
log();
|
|
220
|
+
}
|
|
221
|
+
// ─── restore create ─────────────────────────────────────────────────────────
|
|
222
|
+
export async function restoreCreate(args) {
|
|
223
|
+
const jsonMode = isJsonMode();
|
|
224
|
+
const token = requireToken();
|
|
225
|
+
const config = loadConfig();
|
|
226
|
+
const { branch, at, name, projectId: projectFlag } = parseRestoreCreateArgs(args);
|
|
227
|
+
const projectId = requireProject(projectFlag);
|
|
228
|
+
if (!branch) {
|
|
229
|
+
emitError("MISSING_ARG", "A source branch is required.", "Usage: bata restore create --branch <name-or-id> --at <ISO-timestamp|LSN>");
|
|
230
|
+
}
|
|
231
|
+
if (!at) {
|
|
232
|
+
emitError("MISSING_ARG", "A restore point is required.", "Pass --at <ISO-timestamp|LSN> (an LSN looks like 0/15994B0).");
|
|
233
|
+
}
|
|
234
|
+
const classified = classifyRestorePoint(at);
|
|
235
|
+
if (classified.error) {
|
|
236
|
+
emitError("INVALID_FLAG", classified.error, "");
|
|
237
|
+
}
|
|
238
|
+
const point = classified.point;
|
|
239
|
+
const s = jsonMode ? null : spinner(`Resolving branch ${colors.cyan(branch)}`);
|
|
240
|
+
const sourceBranch = await resolveBranchRef(projectId, token, config.defaultTeam, branch);
|
|
241
|
+
if (!sourceBranch) {
|
|
242
|
+
s?.stop();
|
|
243
|
+
emitError("BRANCH_NOT_FOUND", `Branch "${branch}" not found in this project.`, "List branches with: bata db branches --json");
|
|
244
|
+
}
|
|
245
|
+
const targetName = name || defaultRestoreBranchName(sourceBranch.name);
|
|
246
|
+
s?.update(`Restoring ${colors.cyan(sourceBranch.name)} to ${colors.cyan(point.raw)}`);
|
|
247
|
+
const body = {
|
|
248
|
+
project_id: projectId,
|
|
249
|
+
source_branch_id: sourceBranch.id,
|
|
250
|
+
target_branch_name: targetName,
|
|
251
|
+
restore_point: point.kind === "lsn" ? { lsn: point.value } : { timestamp: point.value },
|
|
252
|
+
};
|
|
253
|
+
const res = await api.post("/v1/restore", body, token);
|
|
254
|
+
s?.stop();
|
|
255
|
+
if (!res.ok) {
|
|
256
|
+
const respBody = res.data;
|
|
257
|
+
if (isRetryable({ status: res.status, code: respBody?.code, message: respBody?.error })) {
|
|
258
|
+
emitError("API_UNAVAILABLE", apiError(res, "Restore failed"), "transient upstream error — retry in a few seconds");
|
|
259
|
+
}
|
|
260
|
+
const code = res.status === 401 || res.status === 403 ? "INVALID_KEY" : "CLI_ERROR";
|
|
261
|
+
emitError(code, apiError(res, "Restore failed"), "");
|
|
262
|
+
}
|
|
263
|
+
const restored = res.data.restored_branch;
|
|
264
|
+
if (!restored) {
|
|
265
|
+
emitError("CLI_ERROR", res.data.error || "Restore did not return a branch.", "");
|
|
266
|
+
}
|
|
267
|
+
const queryHint = `bata db query "SELECT 1" --branch ${restored.id}`;
|
|
268
|
+
if (jsonMode) {
|
|
269
|
+
json({
|
|
270
|
+
restored_branch: {
|
|
271
|
+
id: restored.id,
|
|
272
|
+
name: restored.name,
|
|
273
|
+
parent_branch_id: restored.parent_branch_id,
|
|
274
|
+
restore_point: restored.restore_point,
|
|
275
|
+
resolved_lsn: restored.resolved_lsn,
|
|
276
|
+
compute_id: restored.compute_id,
|
|
277
|
+
},
|
|
278
|
+
source_branch_id: sourceBranch.id,
|
|
279
|
+
operation_id: res.data.operation_id ?? null,
|
|
280
|
+
// A NEW branch — the source branch's data is untouched.
|
|
281
|
+
non_destructive: true,
|
|
282
|
+
// The branch row exists immediately; its compute may still be
|
|
283
|
+
// provisioning — poll before querying.
|
|
284
|
+
ready: false,
|
|
285
|
+
poll: "bata db branches --json",
|
|
286
|
+
query_hint: queryHint,
|
|
287
|
+
});
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
log();
|
|
291
|
+
success(`Restore initiated — created a NEW branch (no data on ${colors.cyan(sourceBranch.name)} was changed)`);
|
|
292
|
+
log();
|
|
293
|
+
kvList([
|
|
294
|
+
["New branch", `${colors.cyan(restored.name)} ${colors.dim(restored.id)}`],
|
|
295
|
+
["Restored from", `${sourceBranch.name} at ${point.raw} ${colors.dim(`(${point.kind})`)}`],
|
|
296
|
+
["Resolved LSN", restored.resolved_lsn],
|
|
297
|
+
["Operation", res.data.operation_id ?? "-"],
|
|
298
|
+
]);
|
|
299
|
+
log();
|
|
300
|
+
log(` ${colors.dim("Poll readiness with")} ${colors.cyan("bata db branches")}${colors.dim(", then query it:")}`);
|
|
301
|
+
log(` ${colors.cyan(queryHint)}`);
|
|
302
|
+
log();
|
|
303
|
+
}
|
|
304
|
+
// ─── Help + dispatch ─────────────────────────────────────────────────────────
|
|
305
|
+
function hasHelpFlag(args) {
|
|
306
|
+
return args.some((a) => a === "--help" || a === "-h");
|
|
307
|
+
}
|
|
308
|
+
function restoreHelp(sub) {
|
|
309
|
+
const usage = (line) => log(` ${colors.cyan(line)}`);
|
|
310
|
+
const note = (line) => log(` ${colors.dim(line)}`);
|
|
311
|
+
log();
|
|
312
|
+
switch (sub) {
|
|
313
|
+
case "points":
|
|
314
|
+
log(` ${colors.bold("bata restore points")} — list recovery points for a project`);
|
|
315
|
+
log();
|
|
316
|
+
usage("bata restore points [--project <id>] [--json]");
|
|
317
|
+
log();
|
|
318
|
+
note("Shows recorded recovery points, each branch's live LSN, and the PITR window.");
|
|
319
|
+
note("Points are metadata snapshots (~every 6h), not full backups — see below.");
|
|
320
|
+
break;
|
|
321
|
+
case "create":
|
|
322
|
+
log(` ${colors.bold("bata restore create")} — restore a branch to a point in time`);
|
|
323
|
+
log();
|
|
324
|
+
usage("bata restore create --branch <name-or-id> --at <ISO-timestamp|LSN> [--name <newBranch>] [--json]");
|
|
325
|
+
log();
|
|
326
|
+
note("Non-destructive: creates a NEW branch at the chosen point; the source");
|
|
327
|
+
note("branch's data is never overwritten. --at accepts an ISO-8601 timestamp");
|
|
328
|
+
note("(e.g. 2026-07-04T12:00:00Z) or an LSN (e.g. 0/15994B0).");
|
|
329
|
+
note("--name defaults to restore-<branch>-<timestamp> if omitted.");
|
|
330
|
+
break;
|
|
331
|
+
default:
|
|
332
|
+
log(` ${colors.bold("bata restore")} — point-in-time restore (PITR)`);
|
|
333
|
+
log();
|
|
334
|
+
usage("bata restore points List recovery points + the PITR window");
|
|
335
|
+
usage("bata restore create Restore a branch to a timestamp/LSN as a NEW branch");
|
|
336
|
+
log();
|
|
337
|
+
note("Restore is non-destructive — it always creates a new branch.");
|
|
338
|
+
note("Add --help to a subcommand for details (e.g. bata restore create --help).");
|
|
339
|
+
}
|
|
340
|
+
log();
|
|
341
|
+
}
|
|
342
|
+
export async function handleRestore(args) {
|
|
343
|
+
const sub = args[0];
|
|
344
|
+
if (hasHelpFlag(args) || !sub) {
|
|
345
|
+
restoreHelp(sub);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
switch (sub) {
|
|
349
|
+
case "points":
|
|
350
|
+
return restorePoints(args.slice(1));
|
|
351
|
+
case "create":
|
|
352
|
+
return restoreCreate(args.slice(1));
|
|
353
|
+
default:
|
|
354
|
+
emitError("INVALID_FLAG", `Unknown subcommand: restore ${sub}`, "Available: points, create");
|
|
355
|
+
}
|
|
356
|
+
}
|
package/dist/commands/schema.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { api, apiError } from "../api.js";
|
|
3
|
-
import { requireToken,
|
|
3
|
+
import { requireToken, isJsonMode } from "../config.js";
|
|
4
4
|
import { colors, log, json, spinner, table, heading } from "../utils/logger.js";
|
|
5
5
|
import { emitError } from "../utils/errors.js";
|
|
6
|
+
import { resolveProjectId } from "../link.js";
|
|
6
7
|
const NOT_IMPLEMENTED_HINT = "Use `bata schema check <file.sql> --fail-on breaking` to gate migrations today.";
|
|
7
8
|
/**
|
|
8
9
|
* Unimplemented schema subcommand. Never exits 0 for a no-op: emits the
|
|
@@ -90,10 +91,10 @@ async function schemaCheck(args) {
|
|
|
90
91
|
emitError("EMPTY_INPUT", "DDL input was empty.", "Provide a schema change to check, e.g. ALTER TABLE orders DROP COLUMN status;");
|
|
91
92
|
}
|
|
92
93
|
const token = requireToken();
|
|
93
|
-
|
|
94
|
-
const projectId =
|
|
94
|
+
// Precedence: .batadata link > config default (schema check has no --project).
|
|
95
|
+
const projectId = resolveProjectId().projectId;
|
|
95
96
|
if (!projectId) {
|
|
96
|
-
emitError("NO_PROJECT", "No default project set.", "
|
|
97
|
+
emitError("NO_PROJECT", "No default project set.", "Run `bata link <project>` or set a default with: bata projects info <id>");
|
|
97
98
|
}
|
|
98
99
|
const body = { sql: ddl, timeRange };
|
|
99
100
|
if (branchId)
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,8 @@ 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";
|
|
15
|
+
import { link, unlink } from "./commands/link.js";
|
|
14
16
|
import { parseGlobalFlags } from "./args.js";
|
|
15
17
|
import { isJsonMode } from "./config.js";
|
|
16
18
|
import { colors, log, banner } from "./utils/logger.js";
|
|
@@ -26,6 +28,8 @@ function help() {
|
|
|
26
28
|
log(` ${colors.cyan("connect <name>")} Open psql to a project (auto-wakes if suspended)`);
|
|
27
29
|
log(` ${colors.cyan("status")} Show all projects and their status`);
|
|
28
30
|
log(` ${colors.cyan("usage")} Per-dimension cost for the current period`);
|
|
31
|
+
log(` ${colors.cyan("link [project]")} Link this directory to a project ${colors.dim("(no more --project)")}`);
|
|
32
|
+
log(` ${colors.cyan("unlink")} Remove this directory's project link`);
|
|
29
33
|
log();
|
|
30
34
|
log(` ${colors.bold("Auth")}`);
|
|
31
35
|
log(` ${colors.cyan("login")} Log in to BataDB`);
|
|
@@ -46,9 +50,14 @@ function help() {
|
|
|
46
50
|
log(` ${colors.cyan("db branches")} List database branches ${colors.dim("(STATUS shows compute readiness)")}`);
|
|
47
51
|
log(` ${colors.cyan("db branch create")} Create a new branch`);
|
|
48
52
|
log(` ${colors.cyan("db branch delete")} Delete a branch`);
|
|
53
|
+
log(` ${colors.cyan("db branch checkout")} Pin a branch into ${colors.dim(".batadata/project.json")}`);
|
|
49
54
|
log(` ${colors.cyan("db studio")} Open table browser in browser`);
|
|
50
55
|
log(` ${colors.cyan("db query")} Run a SQL query ${colors.dim("(--branch <id> to target a branch)")}`);
|
|
51
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();
|
|
52
61
|
log(` ${colors.bold("Schema & Types")}`);
|
|
53
62
|
log(` ${colors.cyan("generate")} Generate types from database schema`);
|
|
54
63
|
log(` ${colors.cyan("generate --watch")} Watch mode for type generation`);
|
|
@@ -121,6 +130,12 @@ async function main() {
|
|
|
121
130
|
case "usage":
|
|
122
131
|
await usage(rest);
|
|
123
132
|
break;
|
|
133
|
+
case "link":
|
|
134
|
+
await link(rest);
|
|
135
|
+
break;
|
|
136
|
+
case "unlink":
|
|
137
|
+
unlink();
|
|
138
|
+
break;
|
|
124
139
|
// Auth
|
|
125
140
|
case "login":
|
|
126
141
|
await login();
|
|
@@ -149,6 +164,10 @@ async function main() {
|
|
|
149
164
|
case "db":
|
|
150
165
|
await handleDb(rest);
|
|
151
166
|
break;
|
|
167
|
+
// Restore (PITR)
|
|
168
|
+
case "restore":
|
|
169
|
+
await handleRestore(rest);
|
|
170
|
+
break;
|
|
152
171
|
// Schema
|
|
153
172
|
case "schema":
|
|
154
173
|
await handleSchema(rest);
|