@batadata/cli 0.1.9 → 0.1.10

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.
@@ -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,14 @@ 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
+ function parseQueryFlags(args) {
501
503
  let branchId;
504
+ let at;
502
505
  const rest = [];
503
506
  for (let i = 0; i < args.length; i++) {
504
507
  const arg = args[i];
@@ -508,15 +511,117 @@ function parseBranchFlag(args) {
508
511
  else if (arg.startsWith("--branch=")) {
509
512
  branchId = arg.slice("--branch=".length);
510
513
  }
514
+ else if (arg === "--at") {
515
+ at = args[++i];
516
+ }
517
+ else if (arg.startsWith("--at=")) {
518
+ at = arg.slice("--at=".length);
519
+ }
511
520
  else {
512
521
  rest.push(arg);
513
522
  }
514
523
  }
515
- return { branchId, rest };
524
+ return { branchId, at, rest };
525
+ }
526
+ /**
527
+ * Resolve the branch a query should target: an explicit `--branch <id-or-name>`
528
+ * (resolved against the project's branch list) or the project's primary branch.
529
+ * Exits (via emitError) with BRANCH_NOT_FOUND if nothing matches.
530
+ */
531
+ async function resolveQueryBranchId(projectId, token, teamId, branchRef) {
532
+ if (branchRef) {
533
+ const branch = await resolveBranchRef(projectId, token, teamId, branchRef);
534
+ if (!branch) {
535
+ emitError("BRANCH_NOT_FOUND", `Branch "${branchRef}" not found in this project.`, "List branches with: bata db branches --json");
536
+ }
537
+ return branch.id;
538
+ }
539
+ const branch = await getPrimaryBranch(projectId, token, teamId);
540
+ if (!branch) {
541
+ emitError("BRANCH_NOT_FOUND", "No branch found for this project to run the query against.", "Check the project with: bata db branches --json");
542
+ }
543
+ return branch.id;
544
+ }
545
+ /**
546
+ * Run a time-travel query: POST /v1/time-travel/query, which forks a hidden
547
+ * ephemeral branch AS OF the point and runs the SQL there. Handles the cold-fork
548
+ * 503 contract — the response always carries the ephemeral `branch` handle so an
549
+ * agent can back off (exit 6) and retry the query itself via
550
+ * `bata db query <sql> --branch <returned-id>`.
551
+ */
552
+ async function runTimeTravelQuery(opts) {
553
+ const { projectId, branchId, point, sql, token, jsonMode } = opts;
554
+ const s = jsonMode ? null : spinner(`Running query as of ${point.raw}`);
555
+ const res = await api.post("/v1/time-travel/query", { project_id: projectId, branch_id: branchId, at: point.value, query: sql }, token);
556
+ s?.stop();
557
+ if (!res.ok) {
558
+ const body = res.data;
559
+ // Cold fork: the ephemeral branch is still starting. Retryable (exit 6). The
560
+ // branch handle rides along so the agent can retry against it via /v1/sql.
561
+ if (isRetryable({ status: res.status, code: body?.code, message: body?.error })) {
562
+ if (jsonMode) {
563
+ json({
564
+ error: apiError(res, "Time-travel branch is starting"),
565
+ code: body?.code ?? "COMPUTE_STARTING",
566
+ hint: "retry the query against the returned branch: bata db query <sql> --branch <branch.id>",
567
+ branch: body?.branch ?? null,
568
+ });
569
+ process.exit(6);
570
+ }
571
+ emitError("COMPUTE_STARTING", apiError(res, "Time-travel branch is starting"), body?.branch
572
+ ? `compute is starting; retry in a few seconds with --branch ${body.branch.id}`
573
+ : "compute is starting; retry in a few seconds");
574
+ }
575
+ const code = res.status === 401 || res.status === 403 ? "INVALID_KEY" : "CLI_ERROR";
576
+ emitError(code, apiError(res, "Time-travel query failed"), "");
577
+ }
578
+ // The route returns an error field only on a real failure it couldn't map to a
579
+ // status; treat it as an error, not a silent success.
580
+ if (res.data.error) {
581
+ emitError("CLI_ERROR", res.data.error, "");
582
+ }
583
+ const columns = res.data.columns ?? [];
584
+ const rows = res.data.rows ?? [];
585
+ const rowCount = res.data.rowCount ?? rows.length;
586
+ const branch = res.data.branch ?? null;
587
+ if (jsonMode) {
588
+ json({
589
+ columns,
590
+ rows,
591
+ row_count: rowCount,
592
+ command: res.data.command ?? null,
593
+ at: point.value,
594
+ // The ephemeral branch the query ran against (auto-reaped after ~10 min).
595
+ branch,
596
+ });
597
+ return;
598
+ }
599
+ if (columns.length && rows.length) {
600
+ log();
601
+ table(columns.map((c) => c.toUpperCase()), rows.map((r) => columns.map((col) => fmtCell(r[col]))));
602
+ log();
603
+ log(` ${colors.dim(`${rowCount} row(s) — as of ${point.raw}`)}`);
604
+ }
605
+ else if (rows.length) {
606
+ const keys = Object.keys(rows[0]);
607
+ log();
608
+ table(keys.map((k) => k.toUpperCase()), rows.map((r) => keys.map((k) => fmtCell(r[k]))));
609
+ log();
610
+ log(` ${colors.dim(`${rowCount} row(s) — as of ${point.raw}`)}`);
611
+ }
612
+ else {
613
+ log();
614
+ const cmd = res.data.command ? `${res.data.command} ` : "";
615
+ log(` ${colors.dim(`${cmd}OK — ${rowCount} row(s), as of ${point.raw}.`)}`);
616
+ }
617
+ if (branch) {
618
+ log(` ${colors.dim(`Ran on ephemeral branch ${branch.id} (auto-deleted ~${new Date(branch.expires_at).toLocaleTimeString()}).`)}`);
619
+ }
620
+ log();
516
621
  }
517
622
  export async function query(args = []) {
518
623
  const jsonMode = isJsonMode();
519
- const { branchId: branchFlag, rest } = parseBranchFlag(args);
624
+ const { branchId: branchFlag, at, rest } = parseQueryFlags(args);
520
625
  // Branch precedence: an explicit --branch wins, else the branch pinned by
521
626
  // `bata db branch checkout` in .batadata/project.json, else the primary.
522
627
  const branchId = resolveBranchId(branchFlag).branchId;
@@ -530,6 +635,24 @@ export async function query(args = []) {
530
635
  if (!projectId) {
531
636
  emitError("NO_PROJECT", "No default project set.", "Set one with: bata projects info <id>");
532
637
  }
638
+ // `--at` is a time-travel query: fork a hidden ephemeral branch AS OF the point
639
+ // and run the SQL there. Classify the point up front so a bad --at fails fast.
640
+ if (at !== undefined) {
641
+ const classified = classifyRestorePoint(at);
642
+ if (classified.error) {
643
+ emitError("INVALID_FLAG", classified.error, "Pass --at <ISO-timestamp|LSN> (an LSN looks like 0/15994B0).");
644
+ }
645
+ // Resolve the SOURCE branch to fork from (id or name), like the normal path.
646
+ const sourceBranchId = await resolveQueryBranchId(projectId, token, config.defaultTeam, branchId);
647
+ return runTimeTravelQuery({
648
+ projectId,
649
+ branchId: sourceBranchId,
650
+ point: classified.point,
651
+ sql,
652
+ token,
653
+ jsonMode,
654
+ });
655
+ }
533
656
  const s = jsonMode ? null : spinner("Running query");
534
657
  // Target branch: an explicit --branch <id-or-name> wins; otherwise resolve
535
658
  // the project's primary branch. /v1/sql/execute is keyed on branch_id, not
@@ -632,12 +755,15 @@ function dbHelp(sub) {
632
755
  log(` ${colors.bold("bata db query")} — run a SQL query against a branch`);
633
756
  log();
634
757
  usage('bata db query "SELECT 1"');
635
- usage("bata db query <sql> [--branch <id-or-name>] [--json]");
758
+ usage("bata db query <sql> [--branch <id-or-name>] [--at <timestamp|LSN>] [--json]");
636
759
  log();
637
760
  note("--branch <id-or-name> Target a specific branch by id OR name (default: the project's primary)");
761
+ note("--at <timestamp|LSN> Time-travel: run the query AS OF a past point (ISO-8601");
762
+ note(" timestamp e.g. 2026-07-04T12:00:00Z, or an LSN e.g. 0/15994B0).");
763
+ note(" Forks a hidden ephemeral branch (auto-deleted ~10 min).");
638
764
  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.");
765
+ note("Cold-start note: a just-created/idle branch (or a fresh --at fork) may answer");
766
+ note("with exit 6 (retryable) while its compute wakes — retry in a few seconds.");
641
767
  break;
642
768
  case "branches":
643
769
  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.10",
4
4
  "description": "CLI for BataDB — serverless Postgres platform",
5
5
  "bin": {
6
6
  "bata": "./dist/index.js"