@batadata/cli 0.2.13 → 0.2.15
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/dist/api.js +2 -1
- package/dist/commands/doctor.d.ts +26 -0
- package/dist/commands/doctor.js +201 -0
- package/dist/index.js +6 -5
- package/dist/utils/logger.js +2 -1
- package/dist/version.d.ts +1 -0
- package/dist/version.js +11 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as http from "node:http";
|
|
|
3
3
|
import { URL } from "node:url";
|
|
4
4
|
import { getApiUrl, loadConfig, getTokenSource, isJsonMode } from "./config.js";
|
|
5
5
|
import { colors } from "./utils/logger.js";
|
|
6
|
+
import { VERSION } from "./version.js";
|
|
6
7
|
export async function request(method, path, options = {}) {
|
|
7
8
|
const baseUrl = getApiUrl();
|
|
8
9
|
const url = new URL(path, baseUrl);
|
|
@@ -15,7 +16,7 @@ export async function request(method, path, options = {}) {
|
|
|
15
16
|
}
|
|
16
17
|
const headers = {
|
|
17
18
|
"Content-Type": "application/json",
|
|
18
|
-
"User-Agent":
|
|
19
|
+
"User-Agent": `@batadata/cli ${VERSION}`,
|
|
19
20
|
};
|
|
20
21
|
if (options.token) {
|
|
21
22
|
headers["Authorization"] = `Bearer ${options.token}`;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bata doctor: run turbine's index doctor against a branch and (optionally)
|
|
3
|
+
* upload the report into the project's index-advice ledger.
|
|
4
|
+
*
|
|
5
|
+
* `turbine doctor --json` (turbine-orm >= 0.46) emits a versioned report
|
|
6
|
+
* (schemaVersion 1) of missing-index findings scored against live statistics.
|
|
7
|
+
* This command wires that to BataDB so a CI job can keep the advice ledger
|
|
8
|
+
* current with one line, instead of hand-rolling a curl of a piped JSON blob.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here interprets the report: the control plane owns the contract, and
|
|
11
|
+
* an unknown schemaVersion is its 400 to give, not ours to guess at.
|
|
12
|
+
*/
|
|
13
|
+
export interface DoctorArgs {
|
|
14
|
+
project?: string;
|
|
15
|
+
branch?: string;
|
|
16
|
+
/** Skip the upload and just print the report. */
|
|
17
|
+
dryRun: boolean;
|
|
18
|
+
/** Pass `--unused`/`--audit` through to turbine doctor. */
|
|
19
|
+
unused: boolean;
|
|
20
|
+
audit: boolean;
|
|
21
|
+
help: boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare function parseDoctorArgs(args: string[]): DoctorArgs | {
|
|
24
|
+
error: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function doctorCommand(args: string[]): Promise<void>;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bata doctor: run turbine's index doctor against a branch and (optionally)
|
|
3
|
+
* upload the report into the project's index-advice ledger.
|
|
4
|
+
*
|
|
5
|
+
* `turbine doctor --json` (turbine-orm >= 0.46) emits a versioned report
|
|
6
|
+
* (schemaVersion 1) of missing-index findings scored against live statistics.
|
|
7
|
+
* This command wires that to BataDB so a CI job can keep the advice ledger
|
|
8
|
+
* current with one line, instead of hand-rolling a curl of a piped JSON blob.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here interprets the report: the control plane owns the contract, and
|
|
11
|
+
* an unknown schemaVersion is its 400 to give, not ours to guess at.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { api, apiError } from "../api.js";
|
|
15
|
+
import { requireToken, isJsonMode } from "../config.js";
|
|
16
|
+
import { colors, log, spinner, info as logInfo } from "../utils/logger.js";
|
|
17
|
+
import { emitError } from "../utils/errors.js";
|
|
18
|
+
import { resolveProjectId, readLinkFile } from "../link.js";
|
|
19
|
+
export function parseDoctorArgs(args) {
|
|
20
|
+
const out = { dryRun: false, unused: false, audit: false, help: false };
|
|
21
|
+
for (let i = 0; i < args.length; i++) {
|
|
22
|
+
const a = args[i];
|
|
23
|
+
const takeVal = (flag) => {
|
|
24
|
+
const next = args[i + 1];
|
|
25
|
+
if (next === undefined || next.startsWith("-"))
|
|
26
|
+
return undefined;
|
|
27
|
+
i++;
|
|
28
|
+
return next;
|
|
29
|
+
};
|
|
30
|
+
if (a === "--help" || a === "-h") {
|
|
31
|
+
out.help = true;
|
|
32
|
+
}
|
|
33
|
+
else if (a === "--project" || a === "-p") {
|
|
34
|
+
const v = takeVal("--project");
|
|
35
|
+
if (v === undefined)
|
|
36
|
+
return { error: "--project requires a value" };
|
|
37
|
+
out.project = v;
|
|
38
|
+
}
|
|
39
|
+
else if (a.startsWith("--project=")) {
|
|
40
|
+
out.project = a.slice("--project=".length);
|
|
41
|
+
}
|
|
42
|
+
else if (a === "--branch" || a === "-b") {
|
|
43
|
+
const v = takeVal("--branch");
|
|
44
|
+
if (v === undefined)
|
|
45
|
+
return { error: "--branch requires a value" };
|
|
46
|
+
out.branch = v;
|
|
47
|
+
}
|
|
48
|
+
else if (a.startsWith("--branch=")) {
|
|
49
|
+
out.branch = a.slice("--branch=".length);
|
|
50
|
+
}
|
|
51
|
+
else if (a === "--dry-run") {
|
|
52
|
+
out.dryRun = true;
|
|
53
|
+
}
|
|
54
|
+
else if (a === "--unused") {
|
|
55
|
+
out.unused = true;
|
|
56
|
+
}
|
|
57
|
+
else if (a === "--audit") {
|
|
58
|
+
out.audit = true;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
return { error: `Unknown argument: ${a}` };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
function doctorHelp() {
|
|
67
|
+
log();
|
|
68
|
+
log(` ${colors.bold("bata doctor")}: index advice from turbine doctor, into your ledger`);
|
|
69
|
+
log();
|
|
70
|
+
log(` ${colors.dim("Usage:")}`);
|
|
71
|
+
log(` bata doctor upload [--project <id>] [--branch <name|id>] [--dry-run]`);
|
|
72
|
+
log();
|
|
73
|
+
log(` ${colors.dim("Runs `turbine doctor --json` against the branch's direct connection and")}`);
|
|
74
|
+
log(` ${colors.dim("imports the findings into the project's index-advice ledger. Re-imports")}`);
|
|
75
|
+
log(` ${colors.dim("refresh the numbers; advice you dismissed stays dismissed.")}`);
|
|
76
|
+
log();
|
|
77
|
+
log(` ${colors.dim("--project <id> Target project (default: linked/default project)")}`);
|
|
78
|
+
log(` ${colors.dim("--branch <name|id> Target branch (default: checked-out, else primary)")}`);
|
|
79
|
+
log(` ${colors.dim("--dry-run Print the report; do not upload")}`);
|
|
80
|
+
log(` ${colors.dim("--unused Also collect never-scanned/redundant index advice")}`);
|
|
81
|
+
log(` ${colors.dim("--audit Also audit previously-suggested indexes")}`);
|
|
82
|
+
log();
|
|
83
|
+
log(` ${colors.dim("Requires turbine-orm >= 0.46 (fetched via npx if not installed).")}`);
|
|
84
|
+
log();
|
|
85
|
+
}
|
|
86
|
+
/** Run `turbine doctor --json` and capture stdout. */
|
|
87
|
+
function runTurbineDoctor(databaseUrl, flags) {
|
|
88
|
+
return new Promise((resolve) => {
|
|
89
|
+
const child = spawn("npx", ["--yes", "turbine-orm@latest", "doctor", "--json", ...flags], {
|
|
90
|
+
env: { ...process.env, DATABASE_URL: databaseUrl },
|
|
91
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
92
|
+
});
|
|
93
|
+
let stdout = "";
|
|
94
|
+
let stderr = "";
|
|
95
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
96
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
97
|
+
child.on("error", (err) => {
|
|
98
|
+
resolve({ ok: false, error: `Could not run turbine doctor: ${err.message}` });
|
|
99
|
+
});
|
|
100
|
+
child.on("close", (code) => {
|
|
101
|
+
// doctor exits non-zero when it HAS findings, which is not a failure for
|
|
102
|
+
// us: parse first, and only treat unparseable output as an error.
|
|
103
|
+
const start = stdout.indexOf("{");
|
|
104
|
+
if (start === -1) {
|
|
105
|
+
resolve({
|
|
106
|
+
ok: false,
|
|
107
|
+
error: `turbine doctor produced no JSON report (exit ${code}). ${stderr.trim().slice(0, 300)}`,
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
try {
|
|
112
|
+
resolve({ ok: true, report: JSON.parse(stdout.slice(start)) });
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
resolve({ ok: false, error: `Could not parse the doctor report: ${String(err)}` });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
export async function doctorCommand(args) {
|
|
121
|
+
const sub = args[0] === "upload" ? args.slice(1) : args;
|
|
122
|
+
const parsed = parseDoctorArgs(sub);
|
|
123
|
+
if ("error" in parsed) {
|
|
124
|
+
emitError("INVALID_FLAG", parsed.error, "Usage: bata doctor upload [--project <id>] [--branch <name|id>]");
|
|
125
|
+
}
|
|
126
|
+
if (parsed.help) {
|
|
127
|
+
doctorHelp();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const token = requireToken();
|
|
131
|
+
const projectId = parsed.project ?? resolveProjectId().projectId;
|
|
132
|
+
if (!projectId) {
|
|
133
|
+
emitError("NO_PROJECT", "No project specified.", "Pass --project <id>, or run `bata link <project>` to set a default.");
|
|
134
|
+
}
|
|
135
|
+
const s = spinner("Fetching connection info");
|
|
136
|
+
const connRes = await api.get(`/v1/connection-info/${projectId}`, token, { reveal: "true" });
|
|
137
|
+
if (!connRes.ok || !connRes.data?.connections?.length) {
|
|
138
|
+
s.stop();
|
|
139
|
+
emitError(connRes.status >= 500 || connRes.status === 0 ? "API_UNAVAILABLE" : "NOT_FOUND", apiError(connRes, "Could not fetch connection info for this project."), "");
|
|
140
|
+
}
|
|
141
|
+
// Branch precedence matches `db query`: --branch, else the checked-out branch
|
|
142
|
+
// when it belongs to this project, else primary.
|
|
143
|
+
const link = readLinkFile()?.link;
|
|
144
|
+
const pinned = link && link.projectId === projectId ? link.branchId ?? undefined : undefined;
|
|
145
|
+
const branchRef = parsed.branch ?? pinned;
|
|
146
|
+
const conns = connRes.data.connections;
|
|
147
|
+
const conn = branchRef
|
|
148
|
+
? conns.find((c) => c.branch_id === branchRef || c.branch_name === branchRef)
|
|
149
|
+
: conns.find((c) => c.is_primary) ?? conns[0];
|
|
150
|
+
if (!conn?.direct) {
|
|
151
|
+
s.stop();
|
|
152
|
+
emitError("BRANCH_NOT_FOUND", branchRef
|
|
153
|
+
? `Branch "${branchRef}" not found, or it has no direct connection.`
|
|
154
|
+
: "No direct connection string available for this project.", "List branches with: bata db branches --json");
|
|
155
|
+
}
|
|
156
|
+
s.update("Running turbine doctor");
|
|
157
|
+
const flags = [];
|
|
158
|
+
if (parsed.unused)
|
|
159
|
+
flags.push("--unused");
|
|
160
|
+
if (parsed.audit)
|
|
161
|
+
flags.push("--audit");
|
|
162
|
+
const result = await runTurbineDoctor(conn.direct, flags);
|
|
163
|
+
s.stop();
|
|
164
|
+
if (!result.ok) {
|
|
165
|
+
emitError("CLI_ERROR", result.error, "Requires turbine-orm >= 0.46 for `doctor --json`.");
|
|
166
|
+
}
|
|
167
|
+
const report = result.report;
|
|
168
|
+
const findingCount = Array.isArray(report.findings) ? report.findings.length : 0;
|
|
169
|
+
if (parsed.dryRun) {
|
|
170
|
+
if (isJsonMode()) {
|
|
171
|
+
log(JSON.stringify(report, null, 2));
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
logInfo(`${findingCount} finding(s); not uploaded (--dry-run)`);
|
|
175
|
+
log(JSON.stringify(report, null, 2));
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const s2 = spinner("Uploading to the index-advice ledger");
|
|
180
|
+
const importRes = await api.post(`/v1/projects/${projectId}/index-advice/import`, report, token);
|
|
181
|
+
s2.stop();
|
|
182
|
+
if (!importRes.ok) {
|
|
183
|
+
emitError(importRes.status === 401 || importRes.status === 403
|
|
184
|
+
? "INVALID_KEY"
|
|
185
|
+
: importRes.status >= 500 || importRes.status === 0
|
|
186
|
+
? "API_UNAVAILABLE"
|
|
187
|
+
: "CLI_ERROR", apiError(importRes, "Could not import the doctor report."), "");
|
|
188
|
+
}
|
|
189
|
+
const data = importRes.data;
|
|
190
|
+
if (isJsonMode()) {
|
|
191
|
+
log(JSON.stringify({ ...data, branch: conn.branch_name ?? conn.branch_id }, null, 2));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
logInfo(`Imported ${colors.bold(String(data.imported))} new and refreshed ${colors.bold(String(data.updated))} existing recommendation(s)` +
|
|
195
|
+
(conn.branch_name ? ` from ${colors.cyan(conn.branch_name)}` : ""));
|
|
196
|
+
if (data.invalidIndexes > 0) {
|
|
197
|
+
log(` ${colors.dim(`${data.invalidIndexes} invalid index(es) reported; see the console for cleanup SQL.`)}`);
|
|
198
|
+
}
|
|
199
|
+
log(` ${colors.dim("Review them in the console under Insights, or with:")}`);
|
|
200
|
+
log(` ${colors.dim(`bata db query "..." --json`)} ${colors.dim("/ GET /v1/projects/" + projectId + "/index-advice")}`);
|
|
201
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -18,16 +18,13 @@ import { handleRestore } from "./commands/restore.js";
|
|
|
18
18
|
import { handlePowdb } from "./commands/powdb.js";
|
|
19
19
|
import { importDb } from "./commands/import.js";
|
|
20
20
|
import { studio } from "./commands/studio.js";
|
|
21
|
+
import { doctorCommand } from "./commands/doctor.js";
|
|
21
22
|
import { link, unlink } from "./commands/link.js";
|
|
22
23
|
import { parseGlobalFlags } from "./args.js";
|
|
23
24
|
import { isJsonMode } from "./config.js";
|
|
24
25
|
import { colors, log, banner } from "./utils/logger.js";
|
|
25
26
|
import { exitCodeFor, isRetryable } from "./utils/errors.js";
|
|
26
|
-
import {
|
|
27
|
-
// Read the version from package.json (dist/index.js → ../package.json) instead
|
|
28
|
-
// of a hardcoded constant — the constant sat at "0.1.4" while releases shipped
|
|
29
|
-
// through 0.1.19, so every published CLI misreported `--version`.
|
|
30
|
-
const VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
27
|
+
import { VERSION } from "./version.js";
|
|
31
28
|
function help() {
|
|
32
29
|
banner();
|
|
33
30
|
log(` ${colors.bold("Usage")}`);
|
|
@@ -65,6 +62,7 @@ function help() {
|
|
|
65
62
|
log(` ${colors.cyan("db branch checkout")} Pin a branch into ${colors.dim(".batadata/project.json")}`);
|
|
66
63
|
log(` ${colors.cyan("db studio")} Open table browser in browser`);
|
|
67
64
|
log(` ${colors.cyan("studio")} Run Turbine Studio locally against a branch ${colors.dim("(local UI, direct TCP)")}`);
|
|
65
|
+
log(` ${colors.cyan("doctor upload")} Import turbine index advice into the project ledger`);
|
|
68
66
|
log(` ${colors.cyan("db query")} Run a SQL query ${colors.dim("(--branch <id> to target a branch)")}`);
|
|
69
67
|
log();
|
|
70
68
|
log(` ${colors.bold("Compute")}`);
|
|
@@ -223,6 +221,9 @@ async function main() {
|
|
|
223
221
|
await importDb(rest);
|
|
224
222
|
break;
|
|
225
223
|
// Local Turbine Studio against a branch
|
|
224
|
+
case "doctor":
|
|
225
|
+
await doctorCommand(rest);
|
|
226
|
+
break;
|
|
226
227
|
case "studio":
|
|
227
228
|
await studio(rest);
|
|
228
229
|
break;
|
package/dist/utils/logger.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isJsonMode } from "../config.js";
|
|
2
|
+
import { VERSION } from "../version.js";
|
|
2
3
|
// ANSI color codes
|
|
3
4
|
const ESC = "\x1b[";
|
|
4
5
|
const RESET = `${ESC}0m`;
|
|
@@ -124,6 +125,6 @@ export function kvList(items) {
|
|
|
124
125
|
// Banner
|
|
125
126
|
export function banner() {
|
|
126
127
|
log();
|
|
127
|
-
log(` ${colors.cyan(colors.bold("BataDB"))} ${colors.dim(
|
|
128
|
+
log(` ${colors.cyan(colors.bold("BataDB"))} ${colors.dim(`v${VERSION}`)} ${colors.dim("— serverless Postgres platform")}`);
|
|
128
129
|
log();
|
|
129
130
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const VERSION: string;
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
// The single source of truth for the CLI's version.
|
|
3
|
+
//
|
|
4
|
+
// This used to be a hardcoded constant copied into three places, and all three
|
|
5
|
+
// drifted: `--version` read 0.2.12, the help banner said v0.1.4, and the
|
|
6
|
+
// User-Agent we sent to the API claimed 0.1.4 as well. A bug report citing the
|
|
7
|
+
// banner version sent us hunting through the wrong release.
|
|
8
|
+
//
|
|
9
|
+
// Resolved relative to this module's own URL, so it works from dist/version.js
|
|
10
|
+
// regardless of how deeply the importing module is nested.
|
|
11
|
+
export const VERSION = createRequire(import.meta.url)("../package.json").version;
|