@batadata/cli 0.1.9 → 0.1.11

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.
@@ -38,5 +38,17 @@ export declare function branchSetProtected(ref: string | undefined, wantProtecte
38
38
  */
39
39
  export declare function branchCheckout(args: string[]): Promise<void>;
40
40
  export declare function studio(): Promise<void>;
41
+ /**
42
+ * Pull `--branch <ref>` / `--at <ISO-timestamp|LSN>` flags (and their `=` forms)
43
+ * out of the query args and return them plus the remaining args, which join into
44
+ * the SQL string. Keeps `db query` order-independent (flags can sit before or
45
+ * after the SQL) and consistent with how global flags are parsed.
46
+ */
47
+ export declare function parseQueryFlags(args: string[]): {
48
+ branchId?: string;
49
+ at?: string;
50
+ projectId?: string;
51
+ rest: string[];
52
+ };
41
53
  export declare function query(args?: string[]): Promise<void>;
42
54
  export declare function handleDb(args: string[]): Promise<void>;
@@ -7,6 +7,9 @@ import { prompt, confirmDestructive } from "../utils/prompts.js";
7
7
  import { openBrowser } from "../utils/open.js";
8
8
  import { emitError, isRetryable } from "../utils/errors.js";
9
9
  import { resolveProjectId, resolveBranchId, findLinkFile, writeLinkFile } from "../link.js";
10
+ // Share the exact LSN/timestamp discriminator `bata restore` uses so `db query
11
+ // --at` classifies a restore point identically.
12
+ import { classifyRestorePoint } from "./restore.js";
10
13
  async function getConnectionInfo(projectId, token) {
11
14
  // reveal=true so the returned string is actually usable (the owner is asking).
12
15
  const res = await api.get(`/v1/connection-info/${projectId}`, token, { reveal: "true" });
@@ -491,14 +494,15 @@ export async function studio() {
491
494
  openBrowser(studioUrl);
492
495
  }
493
496
  /**
494
- * Pull a `--branch <ref>` / `--branch=<ref>` flag out of the query args and
495
- * return the explicit branch ref an id OR a name, resolved later — plus the
496
- * remaining args, which join into the SQL string. Keeps `db query`
497
- * order-independent (the flag can sit before or after the SQL) and consistent
498
- * with how global flags are parsed.
497
+ * Pull `--branch <ref>` / `--at <ISO-timestamp|LSN>` flags (and their `=` forms)
498
+ * out of the query args and return them plus the remaining args, which join into
499
+ * the SQL string. Keeps `db query` order-independent (flags can sit before or
500
+ * after the SQL) and consistent with how global flags are parsed.
499
501
  */
500
- function parseBranchFlag(args) {
502
+ export function parseQueryFlags(args) {
501
503
  let branchId;
504
+ let at;
505
+ let projectId;
502
506
  const rest = [];
503
507
  for (let i = 0; i < args.length; i++) {
504
508
  const arg = args[i];
@@ -508,15 +512,123 @@ function parseBranchFlag(args) {
508
512
  else if (arg.startsWith("--branch=")) {
509
513
  branchId = arg.slice("--branch=".length);
510
514
  }
515
+ else if (arg === "--at") {
516
+ at = args[++i];
517
+ }
518
+ else if (arg.startsWith("--at=")) {
519
+ at = arg.slice("--at=".length);
520
+ }
521
+ else if (arg === "--project") {
522
+ projectId = args[++i];
523
+ }
524
+ else if (arg.startsWith("--project=")) {
525
+ projectId = arg.slice("--project=".length);
526
+ }
511
527
  else {
512
528
  rest.push(arg);
513
529
  }
514
530
  }
515
- return { branchId, rest };
531
+ return { branchId, at, projectId, rest };
532
+ }
533
+ /**
534
+ * Resolve the branch a query should target: an explicit `--branch <id-or-name>`
535
+ * (resolved against the project's branch list) or the project's primary branch.
536
+ * Exits (via emitError) with BRANCH_NOT_FOUND if nothing matches.
537
+ */
538
+ async function resolveQueryBranchId(projectId, token, teamId, branchRef) {
539
+ if (branchRef) {
540
+ const branch = await resolveBranchRef(projectId, token, teamId, branchRef);
541
+ if (!branch) {
542
+ emitError("BRANCH_NOT_FOUND", `Branch "${branchRef}" not found in this project.`, "List branches with: bata db branches --json");
543
+ }
544
+ return branch.id;
545
+ }
546
+ const branch = await getPrimaryBranch(projectId, token, teamId);
547
+ if (!branch) {
548
+ emitError("BRANCH_NOT_FOUND", "No branch found for this project to run the query against.", "Check the project with: bata db branches --json");
549
+ }
550
+ return branch.id;
551
+ }
552
+ /**
553
+ * Run a time-travel query: POST /v1/time-travel/query, which forks a hidden
554
+ * ephemeral branch AS OF the point and runs the SQL there. Handles the cold-fork
555
+ * 503 contract — the response always carries the ephemeral `branch` handle so an
556
+ * agent can back off (exit 6) and retry the query itself via
557
+ * `bata db query <sql> --branch <returned-id>`.
558
+ */
559
+ async function runTimeTravelQuery(opts) {
560
+ const { projectId, branchId, point, sql, token, jsonMode } = opts;
561
+ const s = jsonMode ? null : spinner(`Running query as of ${point.raw}`);
562
+ const res = await api.post("/v1/time-travel/query", { project_id: projectId, branch_id: branchId, at: point.value, query: sql }, token);
563
+ s?.stop();
564
+ if (!res.ok) {
565
+ const body = res.data;
566
+ // Cold fork: the ephemeral branch is still starting. Retryable (exit 6). The
567
+ // branch handle rides along so the agent can retry against it via /v1/sql.
568
+ if (isRetryable({ status: res.status, code: body?.code, message: body?.error })) {
569
+ if (jsonMode) {
570
+ json({
571
+ error: apiError(res, "Time-travel branch is starting"),
572
+ code: body?.code ?? "COMPUTE_STARTING",
573
+ hint: "retry the query against the returned branch: bata db query <sql> --branch <branch.id>",
574
+ branch: body?.branch ?? null,
575
+ });
576
+ process.exit(6);
577
+ }
578
+ emitError("COMPUTE_STARTING", apiError(res, "Time-travel branch is starting"), body?.branch
579
+ ? `compute is starting; retry in a few seconds with --branch ${body.branch.id}`
580
+ : "compute is starting; retry in a few seconds");
581
+ }
582
+ const code = res.status === 401 || res.status === 403 ? "INVALID_KEY" : "CLI_ERROR";
583
+ emitError(code, apiError(res, "Time-travel query failed"), "");
584
+ }
585
+ // The route returns an error field only on a real failure it couldn't map to a
586
+ // status; treat it as an error, not a silent success.
587
+ if (res.data.error) {
588
+ emitError("CLI_ERROR", res.data.error, "");
589
+ }
590
+ const columns = res.data.columns ?? [];
591
+ const rows = res.data.rows ?? [];
592
+ const rowCount = res.data.rowCount ?? rows.length;
593
+ const branch = res.data.branch ?? null;
594
+ if (jsonMode) {
595
+ json({
596
+ columns,
597
+ rows,
598
+ row_count: rowCount,
599
+ command: res.data.command ?? null,
600
+ at: point.value,
601
+ // The ephemeral branch the query ran against (auto-reaped after ~10 min).
602
+ branch,
603
+ });
604
+ return;
605
+ }
606
+ if (columns.length && rows.length) {
607
+ log();
608
+ table(columns.map((c) => c.toUpperCase()), rows.map((r) => columns.map((col) => fmtCell(r[col]))));
609
+ log();
610
+ log(` ${colors.dim(`${rowCount} row(s) — as of ${point.raw}`)}`);
611
+ }
612
+ else if (rows.length) {
613
+ const keys = Object.keys(rows[0]);
614
+ log();
615
+ table(keys.map((k) => k.toUpperCase()), rows.map((r) => keys.map((k) => fmtCell(r[k]))));
616
+ log();
617
+ log(` ${colors.dim(`${rowCount} row(s) — as of ${point.raw}`)}`);
618
+ }
619
+ else {
620
+ log();
621
+ const cmd = res.data.command ? `${res.data.command} ` : "";
622
+ log(` ${colors.dim(`${cmd}OK — ${rowCount} row(s), as of ${point.raw}.`)}`);
623
+ }
624
+ if (branch) {
625
+ log(` ${colors.dim(`Ran on ephemeral branch ${branch.id} (auto-deleted ~${new Date(branch.expires_at).toLocaleTimeString()}).`)}`);
626
+ }
627
+ log();
516
628
  }
517
629
  export async function query(args = []) {
518
630
  const jsonMode = isJsonMode();
519
- const { branchId: branchFlag, rest } = parseBranchFlag(args);
631
+ const { branchId: branchFlag, at, projectId: projectFlag, rest } = parseQueryFlags(args);
520
632
  // Branch precedence: an explicit --branch wins, else the branch pinned by
521
633
  // `bata db branch checkout` in .batadata/project.json, else the primary.
522
634
  const branchId = resolveBranchId(branchFlag).branchId;
@@ -526,9 +638,28 @@ export async function query(args = []) {
526
638
  }
527
639
  const token = requireToken();
528
640
  const config = loadConfig();
529
- const projectId = resolveProjectId().projectId;
641
+ // Project precedence: --project flag > .batadata link > config default.
642
+ const projectId = resolveProjectId(projectFlag).projectId;
530
643
  if (!projectId) {
531
- emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
644
+ emitError("NO_PROJECT", "No project for db query.", "Pass --project <id>, or run `bata link <project>` to set a default.");
645
+ }
646
+ // `--at` is a time-travel query: fork a hidden ephemeral branch AS OF the point
647
+ // and run the SQL there. Classify the point up front so a bad --at fails fast.
648
+ if (at !== undefined) {
649
+ const classified = classifyRestorePoint(at);
650
+ if (classified.error) {
651
+ emitError("INVALID_FLAG", classified.error, "Pass --at <ISO-timestamp|LSN> (an LSN looks like 0/15994B0).");
652
+ }
653
+ // Resolve the SOURCE branch to fork from (id or name), like the normal path.
654
+ const sourceBranchId = await resolveQueryBranchId(projectId, token, config.defaultTeam, branchId);
655
+ return runTimeTravelQuery({
656
+ projectId,
657
+ branchId: sourceBranchId,
658
+ point: classified.point,
659
+ sql,
660
+ token,
661
+ jsonMode,
662
+ });
532
663
  }
533
664
  const s = jsonMode ? null : spinner("Running query");
534
665
  // Target branch: an explicit --branch <id-or-name> wins; otherwise resolve
@@ -632,12 +763,15 @@ function dbHelp(sub) {
632
763
  log(` ${colors.bold("bata db query")} — run a SQL query against a branch`);
633
764
  log();
634
765
  usage('bata db query "SELECT 1"');
635
- usage("bata db query <sql> [--branch <id-or-name>] [--json]");
766
+ usage("bata db query <sql> [--branch <id-or-name>] [--at <timestamp|LSN>] [--json]");
636
767
  log();
637
768
  note("--branch <id-or-name> Target a specific branch by id OR name (default: the project's primary)");
769
+ note("--at <timestamp|LSN> Time-travel: run the query AS OF a past point (ISO-8601");
770
+ note(" timestamp e.g. 2026-07-04T12:00:00Z, or an LSN e.g. 0/15994B0).");
771
+ note(" Forks a hidden ephemeral branch (auto-deleted ~10 min).");
638
772
  note("--json Emit rows as JSON objects + row_count");
639
- note("Cold-start note: a just-created/idle branch may answer with exit 6");
640
- note("(retryable) while its compute wakes — retry in a few seconds.");
773
+ note("Cold-start note: a just-created/idle branch (or a fresh --at fork) may answer");
774
+ note("with exit 6 (retryable) while its compute wakes — retry in a few seconds.");
641
775
  break;
642
776
  case "branches":
643
777
  log(` ${colors.bold("bata db branches")} — list branches and their compute status`);
@@ -1 +1,6 @@
1
+ /** `model:action` when the shape came from the ORM corpus, else "—" (raw SQL). */
2
+ export declare function ormLabel(q: {
3
+ model?: string | null;
4
+ action?: string | null;
5
+ }): string;
1
6
  export declare function handleSchema(args: string[]): Promise<void>;
@@ -53,6 +53,16 @@ function parseCheckArgs(args) {
53
53
  const riskColor = (r) => r === "breaking" ? colors.red(r.toUpperCase())
54
54
  : r === "risky" || r === "unknown" ? colors.yellow(r.toUpperCase())
55
55
  : colors.green(r.toUpperCase());
56
+ /** `model:action` when the shape came from the ORM corpus, else "—" (raw SQL). */
57
+ export function ormLabel(q) {
58
+ if (q.model && q.action)
59
+ return `${q.model}:${q.action}`;
60
+ if (q.model)
61
+ return q.model;
62
+ if (q.action)
63
+ return q.action;
64
+ return "—";
65
+ }
56
66
  function affectedLabel(a) {
57
67
  if (a.index)
58
68
  return `index ${a.index}`;
@@ -145,8 +155,11 @@ function renderReport(v) {
145
155
  log(` ${colors.dim(f.statement)}`);
146
156
  if (f.impactedQueries.length) {
147
157
  const shown = f.impactedQueries.slice(0, 8);
148
- table(["QUERY", "CALLS", "MEAN", "RISK"], shown.map((q) => [
149
- q.query.length > 60 ? q.query.slice(0, 57) + "" : q.query,
158
+ // Show model:action (from the ORM corpus) when present — the differentiator
159
+ // over raw pg_stat_statements shapes, which have no ORM semantics ("").
160
+ table(["MODEL:ACTION", "QUERY", "CALLS", "MEAN", "RISK"], shown.map((q) => [
161
+ ormLabel(q),
162
+ q.query.length > 48 ? q.query.slice(0, 45) + "…" : q.query,
150
163
  q.calls.toLocaleString(),
151
164
  `${q.meanTimeMs}ms`,
152
165
  q.risk,
@@ -11,3 +11,14 @@
11
11
  * We will only show a dollar figure for a dimension we genuinely meter.
12
12
  */
13
13
  export declare function usage(args: string[]): Promise<void>;
14
+ /**
15
+ * Format an estimated cost for human output. Cost-truth: `null` means "no
16
+ * compute metered", rendered as "not metered yet" — NEVER "$0.00" (that would
17
+ * read as a real priced zero). A sub-cent but non-zero estimate is "<$0.01" so
18
+ * it never collapses to $0.00; sub-dollar keeps 4dp to preserve fractional cents.
19
+ */
20
+ export declare function estCost(cents: number | null): string;
21
+ export declare function modelActionLabel(q: {
22
+ model: string | null;
23
+ action: string | null;
24
+ }): string;
@@ -14,6 +14,7 @@ import { api, apiError } from "../api.js";
14
14
  import { requireToken, isJsonMode } from "../config.js";
15
15
  import { colors, log, json, spinner, table, heading, kvList } from "../utils/logger.js";
16
16
  import { emitError } from "../utils/errors.js";
17
+ import { resolveProjectId } from "../link.js";
17
18
  /**
18
19
  * Decide whether a dimension is truly metered. A dimension is metered only when
19
20
  * the API explicitly says so (`metered === true`). A missing `metered` field is
@@ -44,15 +45,24 @@ function valueLabel(d) {
44
45
  return `${d.value} ${d.unit}`;
45
46
  }
46
47
  function parseProjectFlag(args) {
48
+ return parseValueFlag(args, "--project");
49
+ }
50
+ /** Read `--name value` or `--name=value`; undefined if absent. */
51
+ function parseValueFlag(args, name) {
52
+ const eq = `${name}=`;
47
53
  for (let i = 0; i < args.length; i++) {
48
- if (args[i] === "--project" && args[i + 1])
54
+ if (args[i] === name && args[i + 1])
49
55
  return args[i + 1];
50
- if (args[i].startsWith("--project="))
51
- return args[i].slice("--project=".length);
56
+ if (args[i].startsWith(eq))
57
+ return args[i].slice(eq.length);
52
58
  }
53
59
  return undefined;
54
60
  }
55
61
  export async function usage(args) {
62
+ // `--by-query` is a per-project cost-attribution view (Lever L1): which ORM
63
+ // queries drive the project's compute bill. It's estimated, not metered.
64
+ if (args.includes("--by-query"))
65
+ return usageByQuery(args);
56
66
  const jsonMode = isJsonMode();
57
67
  const token = requireToken();
58
68
  const projectFilter = parseProjectFlag(args);
@@ -162,3 +172,112 @@ export async function usage(args) {
162
172
  log();
163
173
  }
164
174
  }
175
+ /**
176
+ * Format an estimated cost for human output. Cost-truth: `null` means "no
177
+ * compute metered", rendered as "not metered yet" — NEVER "$0.00" (that would
178
+ * read as a real priced zero). A sub-cent but non-zero estimate is "<$0.01" so
179
+ * it never collapses to $0.00; sub-dollar keeps 4dp to preserve fractional cents.
180
+ */
181
+ export function estCost(cents) {
182
+ if (cents === null)
183
+ return colors.dim("not metered yet");
184
+ if (cents <= 0)
185
+ return "$0.00";
186
+ const d = cents / 100;
187
+ if (d < 0.01)
188
+ return "<$0.01";
189
+ return `$${d.toFixed(d < 1 ? 4 : 2)}`;
190
+ }
191
+ export function modelActionLabel(q) {
192
+ if (q.model && q.action)
193
+ return `${q.model}.${q.action}`;
194
+ if (q.model)
195
+ return q.model;
196
+ if (q.action)
197
+ return q.action;
198
+ return colors.dim("raw");
199
+ }
200
+ async function usageByQuery(args) {
201
+ const jsonMode = isJsonMode();
202
+ const token = requireToken();
203
+ // Project precedence: --project flag > .batadata link > config default.
204
+ const { projectId } = resolveProjectId(parseProjectFlag(args));
205
+ if (!projectId) {
206
+ emitError("NO_PROJECT", "No project for --by-query.", "Pass --project <id>, or run `bata link <project>` to set a default.");
207
+ }
208
+ const timeRange = parseValueFlag(args, "--window") ?? "24h";
209
+ const branch = parseValueFlag(args, "--branch");
210
+ const query = { timeRange, orderBy: "totalTime", limit: "20" };
211
+ if (branch)
212
+ query.branch_id = branch;
213
+ const s = jsonMode ? null : spinner("Attributing compute cost by query");
214
+ const res = await api.get(`/v1/insights/${projectId}/turbine-queries`, token, query);
215
+ s?.stop();
216
+ if (!res.ok) {
217
+ emitError(res.status === 401 || res.status === 403 ? "INVALID_KEY"
218
+ : res.status === 404 ? "NO_PROJECT"
219
+ : res.status >= 500 || res.status === 0 ? "API_UNAVAILABLE"
220
+ : "CLI_ERROR", apiError(res, "Failed to fetch per-query cost"), "");
221
+ }
222
+ const rows = res.data?.queries ?? [];
223
+ const basis = res.data?.costBasis ?? {
224
+ method: "no-compute-metered",
225
+ windowCuMs: 0,
226
+ windowComputeCents: null,
227
+ windowTotalTimeMs: 0,
228
+ };
229
+ const notMetered = basis.method === "no-compute-metered";
230
+ if (jsonMode) {
231
+ json({
232
+ project_id: projectId,
233
+ window: timeRange,
234
+ branch_id: branch ?? null,
235
+ // Carry the basis so a consumer can always see HOW the estimate was made
236
+ // and that it is off the billing path.
237
+ cost_basis: {
238
+ method: basis.method,
239
+ estimated: true,
240
+ window_cu_ms: basis.windowCuMs,
241
+ window_compute_cents: basis.windowComputeCents,
242
+ window_total_time_ms: basis.windowTotalTimeMs,
243
+ },
244
+ queries: rows.map((q) => ({
245
+ fingerprint: q.fingerprint,
246
+ model: q.model,
247
+ action: q.action,
248
+ sql_template: q.sqlTemplate,
249
+ calls: q.calls,
250
+ mean_time_ms: q.meanTimeMs,
251
+ time_share: q.costTimeShare,
252
+ // null (not 0) when nothing was metered — never a priced $0.
253
+ estimated_cost_cents: q.estimatedCostCents,
254
+ })),
255
+ });
256
+ return;
257
+ }
258
+ heading("Cost by query — estimated");
259
+ log(` ${colors.dim(`Project ${projectId} · window ${timeRange}${branch ? ` · branch ${branch}` : ""}`)}`);
260
+ log();
261
+ if (rows.length === 0) {
262
+ log(` ${colors.dim("No ORM query traffic reported in this window.")}`);
263
+ log(` ${colors.dim("Wire up createInsightsReporter() to start attributing cost.")}`);
264
+ log();
265
+ return;
266
+ }
267
+ table(["QUERY", "CALLS", "MEAN", "EST. COST"], rows.map((q) => [
268
+ modelActionLabel(q),
269
+ q.calls.toLocaleString(),
270
+ `${q.meanTimeMs}ms`,
271
+ estCost(q.estimatedCostCents),
272
+ ]));
273
+ log();
274
+ // Honest footer: never let the estimate masquerade as a metered charge.
275
+ if (notMetered) {
276
+ log(` ${colors.yellow("!")} ${colors.dim("No compute metered in this window — cost can't be attributed to queries yet (never shown as $0).")}`);
277
+ }
278
+ else {
279
+ log(` ${colors.bold("Window compute:")} ${estCost(basis.windowComputeCents)} ${colors.dim("total (metered)")}`);
280
+ log(` ${colors.dim("Per-query figures are ESTIMATES — Turbine insights are off the billing path; compute is apportioned by each query's share of ORM wall-time.")}`);
281
+ }
282
+ log();
283
+ }
package/dist/index.js CHANGED
@@ -83,6 +83,7 @@ function help() {
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)`);
86
+ log(` ${colors.cyan("usage --by-query [--project <id>] [--json]")} Estimated compute cost per ORM query (off billing path)`);
86
87
  log();
87
88
  log(` ${colors.dim("All errors in --json mode share one envelope:")} ${colors.dim('{ "error", "code", "hint" }')} ${colors.dim("on stderr.")}`);
88
89
  log();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@batadata/cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "CLI for BataDB — serverless Postgres platform",
5
5
  "bin": {
6
6
  "bata": "./dist/index.js"