@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.
- package/dist/commands/db.d.ts +12 -0
- package/dist/commands/db.js +147 -13
- package/dist/commands/schema.d.ts +5 -0
- package/dist/commands/schema.js +15 -2
- package/dist/commands/usage.d.ts +11 -0
- package/dist/commands/usage.js +122 -3
- package/dist/index.js +1 -0
- package/package.json +1 -1
package/dist/commands/db.d.ts
CHANGED
|
@@ -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>;
|
package/dist/commands/db.js
CHANGED
|
@@ -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
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
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
|
|
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 } =
|
|
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
|
-
|
|
641
|
+
// Project precedence: --project flag > .batadata link > config default.
|
|
642
|
+
const projectId = resolveProjectId(projectFlag).projectId;
|
|
530
643
|
if (!projectId) {
|
|
531
|
-
emitError("NO_PROJECT", "No
|
|
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
|
|
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`);
|
package/dist/commands/schema.js
CHANGED
|
@@ -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
|
-
|
|
149
|
-
|
|
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,
|
package/dist/commands/usage.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/usage.js
CHANGED
|
@@ -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] ===
|
|
54
|
+
if (args[i] === name && args[i + 1])
|
|
49
55
|
return args[i + 1];
|
|
50
|
-
if (args[i].startsWith(
|
|
51
|
-
return args[i].slice(
|
|
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();
|