@batadata/cli 0.1.11 → 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.
@@ -1,6 +1,7 @@
1
1
  import { colors, log } from "../utils/logger.js";
2
2
  import { emitError } from "../utils/errors.js";
3
- const NOT_IMPLEMENTED_HINT = "Use `bata schema check <file.sql> --fail-on breaking` to gate migrations today.";
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("create")} Create a new migration ${colors.dim("(not implemented)")}`);
29
- log(` ${colors.cyan("deploy")} Deploy pending migrations ${colors.dim("(not implemented)")}`);
30
- log(` ${colors.cyan("status")} Show migration status ${colors.dim("(not implemented)")}`);
31
- log(` ${colors.cyan("reset")} Reset database ${colors.dim("(not implemented)")}`);
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.yellow("!")} ${colors.dim("Migration commands are not yet implemented. To gate a migration today:")}`);
34
- log(` ${colors.cyan("bata schema check <file.sql> --fail-on breaking")}`);
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,6 +1,109 @@
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>;
88
+ /**
89
+ * Pull `--branch <ref>` / `--project <id>` (and their `=`-joined forms) out of
90
+ * `schema dump` args. Both flags are VALUE-consuming: a silently-ignored
91
+ * `--project` would target the wrong project (the exact db-query bug this
92
+ * command must not repeat). Remaining positionals are returned in `rest`.
93
+ */
94
+ export declare function parseSchemaDumpArgs(args: string[]): {
95
+ branch?: string;
96
+ projectId?: string;
97
+ rest: string[];
98
+ };
99
+ /**
100
+ * Pull `--project <id>` out of `schema diff` args, leaving the two positional
101
+ * branch refs (`<from> <to>`) in `rest`. Consuming the flag value keeps a
102
+ * `--project` from being mistaken for a branch ref.
103
+ */
104
+ export declare function parseSchemaDiffArgs(args: string[]): {
105
+ projectId?: string;
106
+ rest: string[];
107
+ };
6
108
  export declare function handleSchema(args: string[]): Promise<void>;
109
+ export {};
@@ -1,9 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { api, apiError } from "../api.js";
3
- import { requireToken, isJsonMode } from "../config.js";
3
+ import { requireToken, isJsonMode, loadConfig } from "../config.js";
4
4
  import { colors, log, json, spinner, table, heading } from "../utils/logger.js";
5
- import { emitError } from "../utils/errors.js";
6
- import { resolveProjectId } from "../link.js";
5
+ import { emitError, isRetryable } from "../utils/errors.js";
6
+ import { resolveProjectId, resolveBranchId } from "../link.js";
7
7
  const NOT_IMPLEMENTED_HINT = "Use `bata schema check <file.sql> --fail-on breaking` to gate migrations today.";
8
8
  /**
9
9
  * Unimplemented schema subcommand. Never exits 0 for a no-op: emits the
@@ -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 can't be mistaken
23
- * for the positional file path (e.g. `--branch main migration.sql` must not
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 already handled upstream
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 (failOn !== undefined && failOn !== "breaking" && failOn !== "risky") {
82
- emitError("INVALID_FLAG", `Invalid --fail-on value "${failOn}".`, "Use --fail-on breaking or --fail-on risky.");
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 (schema check has no --project).
105
- const projectId = resolveProjectId().projectId;
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 default project set.", "Run `bata link <project>` or set a default with: bata projects info <id>");
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: `unknown` (the checker couldn't assess the change,
131
- // e.g. TRUNCATE / DROP SCHEMA) trips BOTH thresholds, so a gate never goes
132
- // green on an unassessable-but-dangerous migration.
133
- if (failOn === "breaking" && (v.overallRisk === "breaking" || v.overallRisk === "unknown"))
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" && v.overallRisk !== "safe")
177
+ if (failOn === "risky" && gate !== "pass")
136
178
  process.exit(2);
137
179
  }
138
180
  function renderReport(v) {
@@ -178,38 +220,253 @@ function renderReport(v) {
178
220
  log(` ${colors.dim(`· ${n}`)}`);
179
221
  log();
180
222
  }
223
+ /**
224
+ * Pull `--branch <ref>` / `--project <id>` (and their `=`-joined forms) out of
225
+ * `schema dump` args. Both flags are VALUE-consuming: a silently-ignored
226
+ * `--project` would target the wrong project (the exact db-query bug this
227
+ * command must not repeat). Remaining positionals are returned in `rest`.
228
+ */
229
+ export function parseSchemaDumpArgs(args) {
230
+ let branch;
231
+ let projectId;
232
+ const rest = [];
233
+ for (let i = 0; i < args.length; i++) {
234
+ const a = args[i];
235
+ if (a === "--branch")
236
+ branch = args[++i];
237
+ else if (a.startsWith("--branch="))
238
+ branch = a.slice("--branch=".length);
239
+ else if (a === "--project")
240
+ projectId = args[++i];
241
+ else if (a.startsWith("--project="))
242
+ projectId = a.slice("--project=".length);
243
+ else
244
+ rest.push(a);
245
+ }
246
+ return { branch, projectId, rest };
247
+ }
248
+ /**
249
+ * Pull `--project <id>` out of `schema diff` args, leaving the two positional
250
+ * branch refs (`<from> <to>`) in `rest`. Consuming the flag value keeps a
251
+ * `--project` from being mistaken for a branch ref.
252
+ */
253
+ export function parseSchemaDiffArgs(args) {
254
+ let projectId;
255
+ const rest = [];
256
+ for (let i = 0; i < args.length; i++) {
257
+ const a = args[i];
258
+ if (a === "--project")
259
+ projectId = args[++i];
260
+ else if (a.startsWith("--project="))
261
+ projectId = a.slice("--project=".length);
262
+ else
263
+ rest.push(a);
264
+ }
265
+ return { projectId, rest };
266
+ }
267
+ /** List a project's branches (GET /v1/projects/:id). Null on lookup failure. */
268
+ async function listProjectBranches(projectId, token, teamId) {
269
+ const query = {};
270
+ if (teamId)
271
+ query.team_id = teamId;
272
+ const res = await api.get(`/v1/projects/${projectId}`, token, query);
273
+ if (!res.ok)
274
+ return null;
275
+ return res.data.branches ?? [];
276
+ }
277
+ /**
278
+ * Resolve a `--branch <ref>` (id OR name) to a concrete branch id, or the
279
+ * project's primary branch when no ref is given. Agents pass the name they used
280
+ * with `db branch create`, so name resolution is mandatory (id-only 404s).
281
+ * Exits via emitError with BRANCH_NOT_FOUND when nothing matches.
282
+ */
283
+ async function resolveBranchIdForSchema(projectId, token, teamId, ref) {
284
+ const branches = await listProjectBranches(projectId, token, teamId);
285
+ if (!branches) {
286
+ emitError("API_UNAVAILABLE", "Failed to list branches for this project.", "Check the project id and try again.");
287
+ }
288
+ if (ref) {
289
+ const match = branches.find((b) => b.id === ref || b.name === ref);
290
+ if (!match) {
291
+ emitError("BRANCH_NOT_FOUND", `Branch "${ref}" not found in this project.`, "List branches with: bata db branches --json");
292
+ }
293
+ return match.id;
294
+ }
295
+ const primary = branches.find((b) => b.isPrimary) ?? branches[0];
296
+ if (!primary) {
297
+ emitError("BRANCH_NOT_FOUND", "This project has no branches to introspect.", "Create one with: bata db branch create <name>");
298
+ }
299
+ return primary.id;
300
+ }
301
+ /** Shared handling for a failed schema/diff request: retryable → COMPUTE_STARTING (exit 6). */
302
+ function failSchemaRequest(res, fallback) {
303
+ const body = res.data;
304
+ if (isRetryable({ status: res.status, code: body?.code, message: body?.error })) {
305
+ emitError("COMPUTE_STARTING", apiError(res, fallback), "compute is starting; retry in a few seconds");
306
+ }
307
+ const code = res.status === 401 || res.status === 403 ? "INVALID_KEY"
308
+ : res.status === 404 ? "NO_PROJECT"
309
+ : "CLI_ERROR";
310
+ emitError(code, apiError(res, fallback), "");
311
+ }
312
+ async function schemaDump(args) {
313
+ const jsonMode = isJsonMode();
314
+ const { branch: branchFlag, projectId: projectFlag } = parseSchemaDumpArgs(args);
315
+ const token = requireToken();
316
+ const config = loadConfig();
317
+ // Project precedence: --project flag > .batadata link > config default.
318
+ const projectId = resolveProjectId(projectFlag).projectId;
319
+ if (!projectId) {
320
+ emitError("NO_PROJECT", "No project for schema dump.", "Pass --project <id>, or run `bata link <project>` to set a default.");
321
+ }
322
+ // Branch precedence: explicit --branch wins, else the branch pinned by
323
+ // `bata db branch checkout`, else the project's primary branch.
324
+ const branchRef = resolveBranchId(branchFlag).branchId;
325
+ const s = jsonMode ? null : spinner("Introspecting schema");
326
+ const branchId = await resolveBranchIdForSchema(projectId, token, config.defaultTeam, branchRef);
327
+ const res = await api.get(`/v1/schema/${projectId}`, token, { branch_id: branchId });
328
+ s?.stop();
329
+ if (!res.ok)
330
+ failSchemaRequest(res, "Schema introspection failed");
331
+ if (jsonMode) {
332
+ json(res.data); // server payload is the source of truth
333
+ return;
334
+ }
335
+ renderSchema(res.data.branch_id, res.data.schema);
336
+ }
337
+ async function schemaDiff(args) {
338
+ const jsonMode = isJsonMode();
339
+ const { projectId: projectFlag, rest } = parseSchemaDiffArgs(args);
340
+ const [fromRef, toRef] = rest;
341
+ if (!fromRef || !toRef) {
342
+ emitError("MISSING_ARG", "Two branches are required.", "Usage: bata schema diff <from-branch> <to-branch> [--project <id>]");
343
+ }
344
+ const token = requireToken();
345
+ const config = loadConfig();
346
+ const projectId = resolveProjectId(projectFlag).projectId;
347
+ if (!projectId) {
348
+ emitError("NO_PROJECT", "No project for schema diff.", "Pass --project <id>, or run `bata link <project>` to set a default.");
349
+ }
350
+ const s = jsonMode ? null : spinner("Diffing branch schemas");
351
+ const fromId = await resolveBranchIdForSchema(projectId, token, config.defaultTeam, fromRef);
352
+ const toId = await resolveBranchIdForSchema(projectId, token, config.defaultTeam, toRef);
353
+ const res = await api.get(`/v1/schema/${projectId}/diff`, token, { from: fromId, to: toId });
354
+ s?.stop();
355
+ if (!res.ok)
356
+ failSchemaRequest(res, "Schema diff failed");
357
+ if (jsonMode) {
358
+ json(res.data);
359
+ return;
360
+ }
361
+ renderDiff(fromRef, toRef, res.data.diff);
362
+ }
363
+ function nullable(c) {
364
+ return c.isNullable ? "" : colors.dim(" NOT NULL");
365
+ }
366
+ function renderSchema(branchId, schema) {
367
+ heading("Schema");
368
+ log(` ${colors.dim(`branch ${colors.cyan(branchId)} · ${schema.tables.length} table${schema.tables.length === 1 ? "" : "s"}`)}`);
369
+ log();
370
+ if (schema.tables.length === 0) {
371
+ log(` ${colors.dim("No tables in this branch's schema.")}`);
372
+ log();
373
+ return;
374
+ }
375
+ for (const t of schema.tables) {
376
+ log(` ${colors.bold(`${t.schema}.${t.name}`)}`);
377
+ for (const col of t.columns) {
378
+ const pk = col.isPrimaryKey ? colors.yellow(" PK") : "";
379
+ const def = col.defaultValue ? colors.dim(` = ${col.defaultValue}`) : "";
380
+ log(` ${col.name} ${colors.cyan(col.dataType)}${nullable(col)}${pk}${def}`);
381
+ }
382
+ for (const idx of t.indexes) {
383
+ log(` ${colors.dim(`index ${idx.name}`)}`);
384
+ }
385
+ for (const con of t.constraints) {
386
+ const fk = con.foreignTableName ? colors.dim(` -> ${con.foreignTableSchema}.${con.foreignTableName}.${con.foreignColumnName}`) : "";
387
+ log(` ${colors.dim(`constraint ${con.name} (${con.type})`)}${fk}`);
388
+ }
389
+ log();
390
+ }
391
+ }
392
+ function renderDiff(fromRef, toRef, diff) {
393
+ heading("Schema diff");
394
+ log(` ${colors.dim(`${colors.cyan(fromRef)} -> ${colors.cyan(toRef)}`)}`);
395
+ log();
396
+ if (diff.identical) {
397
+ log(` ${colors.green(">")} Schemas are identical.`);
398
+ log();
399
+ return;
400
+ }
401
+ for (const t of diff.addedTables) {
402
+ log(` ${colors.green("+ table")} ${colors.bold(`${t.schema}.${t.name}`)} ${colors.dim(`(${t.columns.length} columns)`)}`);
403
+ }
404
+ for (const t of diff.removedTables) {
405
+ log(` ${colors.red("- table")} ${colors.bold(`${t.schema}.${t.name}`)}`);
406
+ }
407
+ for (const t of diff.changedTables) {
408
+ log(` ${colors.yellow("~ table")} ${colors.bold(`${t.schema}.${t.name}`)}`);
409
+ for (const c of t.addedColumns)
410
+ log(` ${colors.green(`+ column ${c.name} ${c.dataType}`)}`);
411
+ for (const c of t.removedColumns)
412
+ log(` ${colors.red(`- column ${c.name}`)}`);
413
+ for (const c of t.changedColumns) {
414
+ const fields = c.changes.map((ch) => `${ch.field}: ${String(ch.from)} -> ${String(ch.to)}`).join(", ");
415
+ log(` ${colors.yellow(`~ column ${c.name}`)} ${colors.dim(fields)}`);
416
+ }
417
+ for (const i of t.addedIndexes)
418
+ log(` ${colors.green(`+ index ${i.name}`)}`);
419
+ for (const i of t.removedIndexes)
420
+ log(` ${colors.red(`- index ${i.name}`)}`);
421
+ for (const i of t.changedIndexes)
422
+ log(` ${colors.yellow(`~ index ${i.name}`)}`);
423
+ for (const con of t.addedConstraints)
424
+ log(` ${colors.green(`+ constraint ${con.name} (${con.type})`)}`);
425
+ for (const con of t.removedConstraints)
426
+ log(` ${colors.red(`- constraint ${con.name}`)}`);
427
+ for (const con of t.changedConstraints)
428
+ log(` ${colors.yellow(`~ constraint ${con.name}`)}`);
429
+ }
430
+ log();
431
+ }
181
432
  export async function handleSchema(args) {
182
433
  const sub = args[0];
183
434
  switch (sub) {
184
435
  case "check":
185
436
  return schemaCheck(args.slice(1));
437
+ case "dump":
438
+ case "show": // alias — the plan calls it `schema show`; `dump` is the agent-friendly verb
439
+ return schemaDump(args.slice(1));
440
+ case "diff":
441
+ return schemaDiff(args.slice(1));
186
442
  case "init":
187
443
  notImplemented("init");
188
444
  case "push":
189
445
  notImplemented("push");
190
446
  case "pull":
191
447
  notImplemented("pull");
192
- case "diff":
193
- notImplemented("diff");
194
448
  default:
195
449
  log();
196
450
  log(` ${colors.bold("bata schema")} ${colors.dim("— schema safety & management")}`);
197
451
  log();
198
452
  log(` ${colors.dim("Commands:")}`);
199
- log(` ${colors.cyan("check <file|->")} Check a proposed DDL change against live query traffic`);
200
- log(` ${colors.cyan("init")} Initialize schema from existing database ${colors.dim("(coming soon)")}`);
201
- log(` ${colors.cyan("push")} Push schema changes to database ${colors.dim("(coming soon)")}`);
202
- log(` ${colors.cyan("pull")} Pull schema from database ${colors.dim("(coming soon)")}`);
203
- log(` ${colors.cyan("diff")} Show pending schema changes ${colors.dim("(coming soon)")}`);
453
+ log(` ${colors.cyan("check <file|->")} Check a proposed DDL change against live query traffic`);
454
+ log(` ${colors.cyan("dump")} Dump a branch's live schema (tables, columns, indexes, constraints)`);
455
+ log(` ${colors.cyan("diff <from> <to>")} Diff two branches' schemas`);
456
+ log(` ${colors.cyan("init")} Initialize schema from existing database ${colors.dim("(coming soon)")}`);
457
+ log(` ${colors.cyan("push")} Push schema changes to database ${colors.dim("(coming soon)")}`);
458
+ log(` ${colors.cyan("pull")} Pull schema from database ${colors.dim("(coming soon)")}`);
204
459
  log();
205
460
  log(` ${colors.dim("Options:")}`);
206
- log(` ${colors.dim("--branch <id> check against a branch's traffic")}`);
207
- log(` ${colors.dim("--window <1h|24h|7d|30d> corpus window (default 24h)")}`);
461
+ log(` ${colors.dim("--branch <name|id> target a branch (dump; default: primary)")}`);
462
+ log(` ${colors.dim("--project <id> override the linked/default project")}`);
463
+ log(` ${colors.dim("--json machine-readable output (dump/diff)")}`);
464
+ log(` ${colors.dim("--window <1h|24h|7d|30d> check corpus window (default 24h)")}`);
208
465
  log(` ${colors.dim("--fail-on <breaking|risky> exit 2 to gate CI (fail-closed: 'unknown' also trips)")}`);
209
466
  log();
210
467
  log(` ${colors.dim("Examples:")}`);
211
- log(` ${colors.dim("bata schema check migration.sql")}`);
212
- log(` ${colors.dim('echo "ALTER TABLE orders DROP COLUMN status;" | bata schema check - --json')}`);
468
+ log(` ${colors.dim("bata schema dump --branch main --json")}`);
469
+ log(` ${colors.dim("bata schema diff main feature-x --json")}`);
213
470
  log(` ${colors.dim("bata schema check migration.sql --fail-on breaking # CI gate")}`);
214
471
  log();
215
472
  }
@@ -22,3 +22,17 @@ export declare function modelActionLabel(q: {
22
22
  model: string | null;
23
23
  action: string | null;
24
24
  }): string;
25
+ /**
26
+ * Build the /turbine-queries request from `--by-query` flags. Exported + pure so
27
+ * the flag wiring is unit-testable — a flag that parses but is never forwarded
28
+ * (a silently-ignored flag) is an agent-usability bug, so we prove it here.
29
+ *
30
+ * Recognised: `--window <1h|24h|7d|30d>` (default 24h), `--branch <id>`,
31
+ * `--tag <label>` (Lever L2 — filter cost attribution to one query tag).
32
+ */
33
+ export declare function buildByQueryRequest(args: string[]): {
34
+ timeRange: string;
35
+ branch?: string;
36
+ tag?: string;
37
+ query: Record<string, string>;
38
+ };
@@ -197,6 +197,25 @@ export function modelActionLabel(q) {
197
197
  return q.action;
198
198
  return colors.dim("raw");
199
199
  }
200
+ /**
201
+ * Build the /turbine-queries request from `--by-query` flags. Exported + pure so
202
+ * the flag wiring is unit-testable — a flag that parses but is never forwarded
203
+ * (a silently-ignored flag) is an agent-usability bug, so we prove it here.
204
+ *
205
+ * Recognised: `--window <1h|24h|7d|30d>` (default 24h), `--branch <id>`,
206
+ * `--tag <label>` (Lever L2 — filter cost attribution to one query tag).
207
+ */
208
+ export function buildByQueryRequest(args) {
209
+ const timeRange = parseValueFlag(args, "--window") ?? "24h";
210
+ const branch = parseValueFlag(args, "--branch");
211
+ const tag = parseValueFlag(args, "--tag");
212
+ const query = { timeRange, orderBy: "totalTime", limit: "20" };
213
+ if (branch)
214
+ query.branch_id = branch;
215
+ if (tag)
216
+ query.tag = tag;
217
+ return { timeRange, branch, tag, query };
218
+ }
200
219
  async function usageByQuery(args) {
201
220
  const jsonMode = isJsonMode();
202
221
  const token = requireToken();
@@ -205,11 +224,7 @@ async function usageByQuery(args) {
205
224
  if (!projectId) {
206
225
  emitError("NO_PROJECT", "No project for --by-query.", "Pass --project <id>, or run `bata link <project>` to set a default.");
207
226
  }
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;
227
+ const { timeRange, branch, tag, query } = buildByQueryRequest(args);
213
228
  const s = jsonMode ? null : spinner("Attributing compute cost by query");
214
229
  const res = await api.get(`/v1/insights/${projectId}/turbine-queries`, token, query);
215
230
  s?.stop();
@@ -232,6 +247,7 @@ async function usageByQuery(args) {
232
247
  project_id: projectId,
233
248
  window: timeRange,
234
249
  branch_id: branch ?? null,
250
+ tag: tag ?? null,
235
251
  // Carry the basis so a consumer can always see HOW the estimate was made
236
252
  // and that it is off the billing path.
237
253
  cost_basis: {
@@ -245,6 +261,7 @@ async function usageByQuery(args) {
245
261
  fingerprint: q.fingerprint,
246
262
  model: q.model,
247
263
  action: q.action,
264
+ tag: q.tag ?? null,
248
265
  sql_template: q.sqlTemplate,
249
266
  calls: q.calls,
250
267
  mean_time_ms: q.meanTimeMs,
@@ -256,16 +273,22 @@ async function usageByQuery(args) {
256
273
  return;
257
274
  }
258
275
  heading("Cost by query — estimated");
259
- log(` ${colors.dim(`Project ${projectId} · window ${timeRange}${branch ? ` · branch ${branch}` : ""}`)}`);
276
+ log(` ${colors.dim(`Project ${projectId} · window ${timeRange}${branch ? ` · branch ${branch}` : ""}${tag ? ` · tag ${tag}` : ""}`)}`);
260
277
  log();
261
278
  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.")}`);
279
+ if (tag) {
280
+ log(` ${colors.dim(`No ORM query traffic tagged "${tag}" in this window.`)}`);
281
+ }
282
+ else {
283
+ log(` ${colors.dim("No ORM query traffic reported in this window.")}`);
284
+ log(` ${colors.dim("Wire up createInsightsReporter() to start attributing cost.")}`);
285
+ }
264
286
  log();
265
287
  return;
266
288
  }
267
- table(["QUERY", "CALLS", "MEAN", "EST. COST"], rows.map((q) => [
289
+ table(["QUERY", "TAG", "CALLS", "MEAN", "EST. COST"], rows.map((q) => [
268
290
  modelActionLabel(q),
291
+ q.tag ?? colors.dim("—"),
269
292
  q.calls.toLocaleString(),
270
293
  `${q.meanTimeMs}ms`,
271
294
  estCost(q.estimatedCostCents),
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")} Schema management ${colors.dim("(coming soon)")}`);
65
- log(` ${colors.cyan("migrate")} Migration management ${colors.dim("(coming soon)")}`);
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("schema check <file.sql> --fail-on breaking")} Gate a migration (exit 2 if breaking)`);
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)")}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@batadata/cli",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "CLI for BataDB — serverless Postgres platform",
5
5
  "bin": {
6
6
  "bata": "./dist/index.js"