@batadata/cli 0.1.12 → 0.1.13
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/commands/migrate.js +23 -7
- package/dist/commands/schema.d.ts +83 -0
- package/dist/commands/schema.js +59 -17
- package/dist/index.js +4 -4
- package/package.json +1 -1
package/dist/commands/migrate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { colors, log } from "../utils/logger.js";
|
|
2
2
|
import { emitError } from "../utils/errors.js";
|
|
3
|
-
|
|
3
|
+
import { schemaCheck } from "./schema.js";
|
|
4
|
+
const NOT_IMPLEMENTED_HINT = "Use `bata migrate check <file.sql>` to gate migrations today.";
|
|
4
5
|
/**
|
|
5
6
|
* Unimplemented migrate subcommand. Never exits 0 for a no-op: emits the
|
|
6
7
|
* NOT_IMPLEMENTED envelope (exit 3) so agents/CI see a real failure, with a
|
|
@@ -12,6 +13,12 @@ function notImplemented(cmd) {
|
|
|
12
13
|
export async function handleMigrate(args) {
|
|
13
14
|
const sub = args[0];
|
|
14
15
|
switch (sub) {
|
|
16
|
+
case "check":
|
|
17
|
+
// The checker-gated-migration gate (L3): reuse the schema-check engine but
|
|
18
|
+
// default to failing on a breaking/unassessable change — a migration gate
|
|
19
|
+
// should be red by default, no flag required. `--fail-on risky` tightens
|
|
20
|
+
// it; there is no way to make the gate a no-op (that's `schema check`).
|
|
21
|
+
return schemaCheck(args.slice(1), { defaultFailOn: "breaking" });
|
|
15
22
|
case "create":
|
|
16
23
|
notImplemented("create");
|
|
17
24
|
case "deploy":
|
|
@@ -25,13 +32,22 @@ export async function handleMigrate(args) {
|
|
|
25
32
|
log(` ${colors.bold("bata migrate")} ${colors.dim("— database migration management")}`);
|
|
26
33
|
log();
|
|
27
34
|
log(` ${colors.dim("Commands:")}`);
|
|
28
|
-
log(` ${colors.cyan("
|
|
29
|
-
log(` ${colors.cyan("
|
|
30
|
-
log(` ${colors.cyan("
|
|
31
|
-
log(` ${colors.cyan("
|
|
35
|
+
log(` ${colors.cyan("check <file|->")} Gate a migration against live query traffic ${colors.dim("(exit 2 if breaking)")}`);
|
|
36
|
+
log(` ${colors.cyan("create")} Create a new migration ${colors.dim("(not implemented)")}`);
|
|
37
|
+
log(` ${colors.cyan("deploy")} Deploy pending migrations ${colors.dim("(not implemented)")}`);
|
|
38
|
+
log(` ${colors.cyan("status")} Show migration status ${colors.dim("(not implemented)")}`);
|
|
39
|
+
log(` ${colors.cyan("reset")} Reset database ${colors.dim("(not implemented)")}`);
|
|
32
40
|
log();
|
|
33
|
-
log(` ${colors.
|
|
34
|
-
log(` ${colors.
|
|
41
|
+
log(` ${colors.dim("Options (check):")}`);
|
|
42
|
+
log(` ${colors.dim("--project <id> gate a specific project (CI has no link)")}`);
|
|
43
|
+
log(` ${colors.dim("--branch <name|id> check against a branch's corpus")}`);
|
|
44
|
+
log(` ${colors.dim("--window <1h|24h|7d|30d> corpus window (default 24h)")}`);
|
|
45
|
+
log(` ${colors.dim("--fail-on <breaking|risky> threshold (default: breaking)")}`);
|
|
46
|
+
log(` ${colors.dim("--json machine-readable verdict")}`);
|
|
47
|
+
log();
|
|
48
|
+
log(` ${colors.dim("Examples:")}`);
|
|
49
|
+
log(` ${colors.dim("bata migrate check migrations/0007.sql --project prj_123 # CI gate")}`);
|
|
50
|
+
log(` ${colors.dim('cat change.sql | bata migrate check - --fail-on risky --json')}`);
|
|
35
51
|
log();
|
|
36
52
|
}
|
|
37
53
|
}
|
|
@@ -1,8 +1,90 @@
|
|
|
1
|
+
interface ImpactedQuery {
|
|
2
|
+
queryHash: string;
|
|
3
|
+
query: string;
|
|
4
|
+
calls: number;
|
|
5
|
+
meanTimeMs: number;
|
|
6
|
+
risk: "breaking" | "perf-risk";
|
|
7
|
+
reason: string;
|
|
8
|
+
model?: string | null;
|
|
9
|
+
action?: string | null;
|
|
10
|
+
}
|
|
11
|
+
interface Finding {
|
|
12
|
+
statement: string;
|
|
13
|
+
operation: string;
|
|
14
|
+
affected: {
|
|
15
|
+
table: string | null;
|
|
16
|
+
columns: string[];
|
|
17
|
+
index: string | null;
|
|
18
|
+
constraint: string | null;
|
|
19
|
+
};
|
|
20
|
+
risk: "breaking" | "risky" | "safe" | "unknown";
|
|
21
|
+
impactedQueries: ImpactedQuery[];
|
|
22
|
+
callsAtRisk: number;
|
|
23
|
+
suggestion?: string;
|
|
24
|
+
note?: string;
|
|
25
|
+
}
|
|
26
|
+
type GateVerdict = "pass" | "warn" | "fail";
|
|
27
|
+
interface Verdict {
|
|
28
|
+
overallRisk: "breaking" | "risky" | "safe" | "unknown";
|
|
29
|
+
verdict?: GateVerdict;
|
|
30
|
+
summary: {
|
|
31
|
+
statements: number;
|
|
32
|
+
breakingCount: number;
|
|
33
|
+
riskyCount: number;
|
|
34
|
+
safeCount: number;
|
|
35
|
+
unknownCount: number;
|
|
36
|
+
totalCallsAtRisk: number;
|
|
37
|
+
};
|
|
38
|
+
corpus: {
|
|
39
|
+
windowHours: number;
|
|
40
|
+
queryShapes: number;
|
|
41
|
+
totalCalls: number;
|
|
42
|
+
branchId: string | null;
|
|
43
|
+
empty: boolean;
|
|
44
|
+
capped: boolean;
|
|
45
|
+
};
|
|
46
|
+
findings: Finding[];
|
|
47
|
+
notes: string[];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Fail-closed local mirror of the server's gate mapping: breaking/unknown →
|
|
51
|
+
* fail, risky → warn, safe → pass. Used only when the server omits `verdict`
|
|
52
|
+
* (older control-plane); otherwise the server value is authoritative.
|
|
53
|
+
*/
|
|
54
|
+
export declare function deriveVerdict(r: Verdict["overallRisk"]): GateVerdict;
|
|
55
|
+
interface CheckArgs {
|
|
56
|
+
file?: string;
|
|
57
|
+
stdin: boolean;
|
|
58
|
+
branch?: string;
|
|
59
|
+
project?: string;
|
|
60
|
+
window: string;
|
|
61
|
+
failOn?: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Parse `schema check` / `migrate check` args, consuming flag VALUES so they
|
|
65
|
+
* can't be mistaken for the positional file path (e.g. `--branch main
|
|
66
|
+
* migration.sql` must not read a file called "main"). Every value-taking flag
|
|
67
|
+
* supports both the ` ` and `=` form. A silently-ignored `--project` would gate
|
|
68
|
+
* the WRONG project in CI — the exact `db query` bug this must not repeat — so
|
|
69
|
+
* it is consumed as a value here and tested.
|
|
70
|
+
*/
|
|
71
|
+
export declare function parseCheckArgs(args: string[]): CheckArgs;
|
|
1
72
|
/** `model:action` when the shape came from the ORM corpus, else "—" (raw SQL). */
|
|
2
73
|
export declare function ormLabel(q: {
|
|
3
74
|
model?: string | null;
|
|
4
75
|
action?: string | null;
|
|
5
76
|
}): string;
|
|
77
|
+
/**
|
|
78
|
+
* Options for the shared check flow.
|
|
79
|
+
* - `defaultFailOn`: the gate threshold applied when the caller passes no
|
|
80
|
+
* `--fail-on`. `bata schema check` is analysis-first (undefined → exit 0
|
|
81
|
+
* always); `bata migrate check` is a GATE (`breaking` → exit 2 on a breaking
|
|
82
|
+
* or unassessable change even with no flag).
|
|
83
|
+
*/
|
|
84
|
+
export interface SchemaCheckOptions {
|
|
85
|
+
defaultFailOn?: "breaking" | "risky";
|
|
86
|
+
}
|
|
87
|
+
export declare function schemaCheck(args: string[], opts?: SchemaCheckOptions): Promise<void>;
|
|
6
88
|
/**
|
|
7
89
|
* Pull `--branch <ref>` / `--project <id>` (and their `=`-joined forms) out of
|
|
8
90
|
* `schema dump` args. Both flags are VALUE-consuming: a silently-ignored
|
|
@@ -24,3 +106,4 @@ export declare function parseSchemaDiffArgs(args: string[]): {
|
|
|
24
106
|
rest: string[];
|
|
25
107
|
};
|
|
26
108
|
export declare function handleSchema(args: string[]): Promise<void>;
|
|
109
|
+
export {};
|
package/dist/commands/schema.js
CHANGED
|
@@ -12,6 +12,18 @@ const NOT_IMPLEMENTED_HINT = "Use `bata schema check <file.sql> --fail-on breaki
|
|
|
12
12
|
function notImplemented(cmd) {
|
|
13
13
|
emitError("NOT_IMPLEMENTED", `Schema ${cmd} is not yet implemented.`, NOT_IMPLEMENTED_HINT);
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Fail-closed local mirror of the server's gate mapping: breaking/unknown →
|
|
17
|
+
* fail, risky → warn, safe → pass. Used only when the server omits `verdict`
|
|
18
|
+
* (older control-plane); otherwise the server value is authoritative.
|
|
19
|
+
*/
|
|
20
|
+
export function deriveVerdict(r) {
|
|
21
|
+
if (r === "breaking" || r === "unknown")
|
|
22
|
+
return "fail";
|
|
23
|
+
if (r === "risky")
|
|
24
|
+
return "warn";
|
|
25
|
+
return "pass";
|
|
26
|
+
}
|
|
15
27
|
async function readStdin() {
|
|
16
28
|
const chunks = [];
|
|
17
29
|
for await (const chunk of process.stdin)
|
|
@@ -19,11 +31,14 @@ async function readStdin() {
|
|
|
19
31
|
return Buffer.concat(chunks).toString("utf8");
|
|
20
32
|
}
|
|
21
33
|
/**
|
|
22
|
-
* Parse `schema check` args, consuming flag VALUES so they
|
|
23
|
-
* for the positional file path (e.g. `--branch main
|
|
24
|
-
* read a file called "main").
|
|
34
|
+
* Parse `schema check` / `migrate check` args, consuming flag VALUES so they
|
|
35
|
+
* can't be mistaken for the positional file path (e.g. `--branch main
|
|
36
|
+
* migration.sql` must not read a file called "main"). Every value-taking flag
|
|
37
|
+
* supports both the ` ` and `=` form. A silently-ignored `--project` would gate
|
|
38
|
+
* the WRONG project in CI — the exact `db query` bug this must not repeat — so
|
|
39
|
+
* it is consumed as a value here and tested.
|
|
25
40
|
*/
|
|
26
|
-
function parseCheckArgs(args) {
|
|
41
|
+
export function parseCheckArgs(args) {
|
|
27
42
|
const out = { stdin: false, window: "24h" };
|
|
28
43
|
for (let i = 0; i < args.length; i++) {
|
|
29
44
|
const a = args[i];
|
|
@@ -31,20 +46,40 @@ function parseCheckArgs(args) {
|
|
|
31
46
|
out.branch = args[++i];
|
|
32
47
|
continue;
|
|
33
48
|
}
|
|
49
|
+
if (a.startsWith("--branch=")) {
|
|
50
|
+
out.branch = a.slice("--branch=".length);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (a === "--project") {
|
|
54
|
+
out.project = args[++i];
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (a.startsWith("--project=")) {
|
|
58
|
+
out.project = a.slice("--project=".length);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
34
61
|
if (a === "--window") {
|
|
35
62
|
out.window = args[++i] ?? out.window;
|
|
36
63
|
continue;
|
|
37
64
|
}
|
|
65
|
+
if (a.startsWith("--window=")) {
|
|
66
|
+
out.window = a.slice("--window=".length) || out.window;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
38
69
|
if (a === "--fail-on") {
|
|
39
70
|
out.failOn = args[++i];
|
|
40
71
|
continue;
|
|
41
72
|
}
|
|
73
|
+
if (a.startsWith("--fail-on=")) {
|
|
74
|
+
out.failOn = a.slice("--fail-on=".length);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
42
77
|
if (a === "-") {
|
|
43
78
|
out.stdin = true;
|
|
44
79
|
continue;
|
|
45
80
|
}
|
|
46
81
|
if (a.startsWith("-"))
|
|
47
|
-
continue; // unknown/global flag
|
|
82
|
+
continue; // unknown/global flag (e.g. --json) handled upstream
|
|
48
83
|
if (out.file === undefined)
|
|
49
84
|
out.file = a;
|
|
50
85
|
}
|
|
@@ -74,13 +109,16 @@ function affectedLabel(a) {
|
|
|
74
109
|
return a.table;
|
|
75
110
|
return "—";
|
|
76
111
|
}
|
|
77
|
-
async function schemaCheck(args) {
|
|
112
|
+
export async function schemaCheck(args, opts = {}) {
|
|
78
113
|
const jsonMode = isJsonMode();
|
|
79
|
-
const { file, stdin, branch: branchId, window: timeRange, failOn } = parseCheckArgs(args);
|
|
114
|
+
const { file, stdin, branch: branchId, project: projectFlag, window: timeRange, failOn: failOnArg } = parseCheckArgs(args);
|
|
80
115
|
// Reject a typo'd --fail-on value rather than silently disabling the gate.
|
|
81
|
-
if (
|
|
82
|
-
emitError("INVALID_FLAG", `Invalid --fail-on value "${
|
|
116
|
+
if (failOnArg !== undefined && failOnArg !== "breaking" && failOnArg !== "risky") {
|
|
117
|
+
emitError("INVALID_FLAG", `Invalid --fail-on value "${failOnArg}".`, "Use --fail-on breaking or --fail-on risky.");
|
|
83
118
|
}
|
|
119
|
+
// An explicit --fail-on always wins; otherwise fall back to the command's
|
|
120
|
+
// default gate (set for `migrate check`, unset for `schema check`).
|
|
121
|
+
const failOn = failOnArg ?? opts.defaultFailOn;
|
|
84
122
|
// ── read DDL (file path, explicit "-", or piped stdin) ──
|
|
85
123
|
let ddl;
|
|
86
124
|
try {
|
|
@@ -101,10 +139,11 @@ async function schemaCheck(args) {
|
|
|
101
139
|
emitError("EMPTY_INPUT", "DDL input was empty.", "Provide a schema change to check, e.g. ALTER TABLE orders DROP COLUMN status;");
|
|
102
140
|
}
|
|
103
141
|
const token = requireToken();
|
|
104
|
-
// Precedence: .batadata link > config default
|
|
105
|
-
|
|
142
|
+
// Precedence: --project flag > .batadata link > config default. CI runners
|
|
143
|
+
// rarely have a committed link, so an explicit --project is the reliable path.
|
|
144
|
+
const projectId = resolveProjectId(projectFlag).projectId;
|
|
106
145
|
if (!projectId) {
|
|
107
|
-
emitError("NO_PROJECT", "No
|
|
146
|
+
emitError("NO_PROJECT", "No project for schema check.", "Pass --project <id>, run `bata link <project>`, or set a default project.");
|
|
108
147
|
}
|
|
109
148
|
const body = { sql: ddl, timeRange };
|
|
110
149
|
if (branchId)
|
|
@@ -127,12 +166,15 @@ async function schemaCheck(args) {
|
|
|
127
166
|
renderReport(v);
|
|
128
167
|
}
|
|
129
168
|
// Exit 0 on a successful analysis (risk lives in the payload). --fail-on gates
|
|
130
|
-
// for CI and is FAIL-CLOSED: `
|
|
131
|
-
//
|
|
132
|
-
// green on an
|
|
133
|
-
|
|
169
|
+
// for CI on the machine-decidable verdict and is FAIL-CLOSED: `fail` covers
|
|
170
|
+
// both breaking AND unknown (the checker couldn't assess the change, e.g.
|
|
171
|
+
// TRUNCATE / DROP SCHEMA), so a gate never goes green on an
|
|
172
|
+
// unassessable-but-dangerous migration. Fall back to a local derivation when
|
|
173
|
+
// an older server omits `verdict`.
|
|
174
|
+
const gate = v.verdict ?? deriveVerdict(v.overallRisk);
|
|
175
|
+
if (failOn === "breaking" && gate === "fail")
|
|
134
176
|
process.exit(2);
|
|
135
|
-
if (failOn === "risky" &&
|
|
177
|
+
if (failOn === "risky" && gate !== "pass")
|
|
136
178
|
process.exit(2);
|
|
137
179
|
}
|
|
138
180
|
function renderReport(v) {
|
package/dist/index.js
CHANGED
|
@@ -61,8 +61,8 @@ function help() {
|
|
|
61
61
|
log(` ${colors.bold("Schema & Types")}`);
|
|
62
62
|
log(` ${colors.cyan("generate")} Generate types from database schema`);
|
|
63
63
|
log(` ${colors.cyan("generate --watch")} Watch mode for type generation`);
|
|
64
|
-
log(` ${colors.cyan("schema")}
|
|
65
|
-
log(` ${colors.cyan("migrate")}
|
|
64
|
+
log(` ${colors.cyan("schema check|dump|diff")} Schema safety gate + introspection`);
|
|
65
|
+
log(` ${colors.cyan("migrate check")} Gate a migration against live query traffic ${colors.dim("(exit 2 if breaking)")}`);
|
|
66
66
|
log();
|
|
67
67
|
log(` ${colors.bold("Development")}`);
|
|
68
68
|
log(` ${colors.cyan("dev")} Local development setup guide`);
|
|
@@ -79,7 +79,7 @@ function help() {
|
|
|
79
79
|
log(` ${colors.dim("Every command works with just")} ${colors.cyan("BATA_API_KEY")} ${colors.dim("set — no login needed.")}`);
|
|
80
80
|
log(` ${colors.dim("Mint a key with")} ${colors.cyan("bata api-keys create --json")}${colors.dim(".")}`);
|
|
81
81
|
log();
|
|
82
|
-
log(` ${colors.cyan("
|
|
82
|
+
log(` ${colors.cyan("migrate check <file.sql> --project <id>")} Gate a migration in CI (exit 2 if breaking)`);
|
|
83
83
|
log(` ${colors.cyan("db url --json")} Print connection string as JSON`);
|
|
84
84
|
log(` ${colors.cyan("db query <sql> --json")} Run SQL headlessly, rows as JSON objects`);
|
|
85
85
|
log(` ${colors.cyan("usage --json")} Per-dimension cost (honest: un-metered → null)`);
|
|
@@ -90,7 +90,7 @@ function help() {
|
|
|
90
90
|
log(` ${colors.bold("Exit codes")}`);
|
|
91
91
|
log(` ${colors.dim("0")} success`);
|
|
92
92
|
log(` ${colors.dim("1")} generic error ${colors.dim("(CLI_ERROR)")}`);
|
|
93
|
-
log(` ${colors.dim("2")} gate tripped ${colors.dim("(schema check --fail-on)")}`);
|
|
93
|
+
log(` ${colors.dim("2")} gate tripped ${colors.dim("(migrate/schema check --fail-on)")}`);
|
|
94
94
|
log(` ${colors.dim("3")} not implemented ${colors.dim("(NOT_IMPLEMENTED)")}`);
|
|
95
95
|
log(` ${colors.dim("4")} auth / credentials ${colors.dim("(NO_CREDENTIALS, INVALID_KEY)")}`);
|
|
96
96
|
log(` ${colors.dim("5")} not-found / bad input ${colors.dim("(NO_PROJECT, BRANCH_NOT_FOUND, INVALID_FLAG, INTERACTIVE_ONLY)")}`);
|