From 1e165ab1f32fc67c8b34634ce0e60d5f8b8d01d4 Mon Sep 17 00:00:00 2001 From: Hao Yan Date: Sun, 28 Jun 2026 18:34:53 -0600 Subject: [PATCH 1/4] feat(sql): add sql api client methods --- src/api/client.ts | 57 ++++++++++++++-- tests/api/sql-client.test.ts | 122 +++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 tests/api/sql-client.test.ts diff --git a/src/api/client.ts b/src/api/client.ts index 7a2463c..b9a367d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -13,6 +13,32 @@ export type TraceList = Ok200; export type TraceDetail = Ok200; export type TraceExport = Ok200; +// Hand-written types for SQL Gateway endpoints (not in openapi.json). +export interface SqlColumn { + name: string; + type: string; +} +export interface SqlQueryRequest { + query: string; + parameters?: Record; + max_rows?: number; +} +export interface SqlQueryResponse { + columns: SqlColumn[]; + rows: unknown[][]; + row_count: number; + truncated: boolean; + elapsed_ms: number; + statistics: Record; +} +export interface SqlSchemaTable { + name: string; + columns: SqlColumn[]; +} +export interface SqlSchemaResponse { + tables: SqlSchemaTable[]; +} + export interface ApiClientOptions { host: string; apiKey: string; @@ -38,6 +64,8 @@ export interface ApiClient { listTraces(params?: ListTracesParams): Promise; getTrace(traceId: string): Promise; exportTrace(traceId: string): Promise; + sqlQuery(body: SqlQueryRequest): Promise; + sqlSchema(): Promise; } /** Shape of a backend JSON error body. */ @@ -70,17 +98,32 @@ export function createApiClient(opts: ApiClientOptions): ApiClient { accept: "application/json", }; - async function request(path: string): Promise { + async function request( + path: string, + reqInit?: { method?: string; body?: unknown }, + ): Promise { const url = `${base}${path}`; - const init: RequestInit = { method: "GET", headers }; + const method = reqInit?.method ?? "GET"; + + // Merge in content-type only for requests that carry a body. + const requestHeaders = + reqInit?.body !== undefined ? { ...headers, "content-type": "application/json" } : headers; + + const fetchInit: RequestInit = { method, headers: requestHeaders }; + + if (reqInit?.body !== undefined) { + fetchInit.body = JSON.stringify(reqInit.body); + } + if (opts.timeoutMs !== undefined) { // A fresh signal per request; aborts the fetch on timeout so a stalled // socket can't hang the process indefinitely. - init.signal = AbortSignal.timeout(opts.timeoutMs); + fetchInit.signal = AbortSignal.timeout(opts.timeoutMs); } + let res: Response; try { - res = await fetchImpl(url, init); + res = await fetchImpl(url, fetchInit); } catch (err) { // Deliberately do NOT interpolate the underlying error message: it could // echo back request contents and leak the api key. Mention only the host. @@ -129,5 +172,11 @@ export function createApiClient(opts: ApiClientOptions): ApiClient { exportTrace(traceId) { return request(`/api/v1/public/traces/${encodeURIComponent(traceId)}/export`); }, + sqlQuery(body) { + return request("/api/v1/public/sql", { method: "POST", body }); + }, + sqlSchema() { + return request("/api/v1/public/sql/schema"); + }, }; } diff --git a/tests/api/sql-client.test.ts b/tests/api/sql-client.test.ts new file mode 100644 index 0000000..c763fb0 --- /dev/null +++ b/tests/api/sql-client.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { createApiClient } from "../../src/api/client.js"; +import type { SqlQueryResponse, SqlSchemaResponse } from "../../src/api/client.js"; +import { CliError } from "../../src/output.js"; +import { createFakeFetch, errorResponse, jsonResponse } from "../helpers/fakeFetch.js"; + +const API_KEY = "tr_secret_LEAK"; + +function clientWith(responder: Parameters[0], host = "https://h") { + const fake = createFakeFetch(responder); + const client = createApiClient({ host, apiKey: API_KEY, fetchImpl: fake.fetchImpl }); + return { client, calls: fake.calls }; +} + +describe("sqlQuery", () => { + it("POSTs to /api/v1/public/sql with correct method, headers, and body", async () => { + const mockResponse: SqlQueryResponse = { + columns: [ + { name: "id", type: "integer" }, + { name: "name", type: "string" }, + ], + rows: [ + [1, "Alice"], + [2, "Bob"], + ], + row_count: 2, + truncated: false, + elapsed_ms: 42, + statistics: {}, + }; + const { client, calls } = clientWith(() => jsonResponse(mockResponse)); + await client.sqlQuery({ + query: "SELECT * FROM users", + parameters: { model: "gpt-4o" }, + max_rows: 100, + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe("https://h/api/v1/public/sql"); + expect(calls[0]?.init.method).toBe("POST"); + + const headers = new Headers(calls[0]?.init.headers); + expect(headers.get("authorization")).toBe(`Bearer ${API_KEY}`); + expect(headers.get("accept")).toBe("application/json"); + expect(headers.get("content-type")).toBe("application/json"); + + const body = JSON.parse(String(calls[0]?.init.body)); + expect(body.query).toBe("SELECT * FROM users"); + expect(body.parameters).toEqual({ model: "gpt-4o" }); + expect(body.max_rows).toBe(100); + }); + + it("returns the parsed SqlQueryResponse", async () => { + const mockResponse: SqlQueryResponse = { + columns: [{ name: "count", type: "integer" }], + rows: [[42]], + row_count: 1, + truncated: false, + elapsed_ms: 10, + statistics: { bytes_processed: 1024 }, + }; + const { client } = clientWith(() => jsonResponse(mockResponse)); + const result = await client.sqlQuery({ query: "SELECT COUNT(*) FROM traces" }); + expect(result).toEqual(mockResponse); + }); + + it("maps a non-2xx detail response to CliError", async () => { + const { client } = clientWith(() => errorResponse(400, "Unknown table 'users'...")); + await expect(client.sqlQuery({ query: "SELECT * FROM users" })).rejects.toBeInstanceOf( + CliError, + ); + await expect(client.sqlQuery({ query: "SELECT * FROM users" })).rejects.toThrow( + /Unknown table 'users'/, + ); + }); + + it("keeps the api key out of a network-failure CliError via sqlQuery", async () => { + const { client } = clientWith(() => { + throw new Error(`boom ${API_KEY}`); + }); + const err = await client.sqlQuery({ query: "SELECT 1" }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(CliError); + const e = err as CliError; + expect(e.message).not.toContain(API_KEY); + expect(String(e)).not.toContain(API_KEY); + }); +}); + +describe("sqlSchema", () => { + it("GETs /api/v1/public/sql/schema with bearer auth", async () => { + const mockResponse: SqlSchemaResponse = { + tables: [{ name: "traces", columns: [{ name: "id", type: "string" }] }], + }; + const { client, calls } = clientWith(() => jsonResponse(mockResponse)); + await client.sqlSchema(); + + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe("https://h/api/v1/public/sql/schema"); + expect(calls[0]?.init.method).toBe("GET"); + + const headers = new Headers(calls[0]?.init.headers); + expect(headers.get("authorization")).toBe(`Bearer ${API_KEY}`); + expect(headers.get("accept")).toBe("application/json"); + }); + + it("returns the parsed SqlSchemaResponse", async () => { + const mockResponse: SqlSchemaResponse = { + tables: [ + { + name: "traces", + columns: [ + { name: "id", type: "string" }, + { name: "created_at", type: "timestamp" }, + ], + }, + ], + }; + const { client } = clientWith(() => jsonResponse(mockResponse)); + const result = await client.sqlSchema(); + expect(result).toEqual(mockResponse); + }); +}); From b4b8fcd379858c6df27fcc72eb3e180a4af56ccc Mon Sep 17 00:00:00 2001 From: Hao Yan Date: Sun, 28 Jun 2026 18:34:53 -0600 Subject: [PATCH 2/4] feat(sql): add csv renderer for sql output --- src/render/csv.ts | 29 +++++++++++++++++++++ tests/render/csv.test.ts | 56 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 src/render/csv.ts create mode 100644 tests/render/csv.test.ts diff --git a/src/render/csv.ts b/src/render/csv.ts new file mode 100644 index 0000000..7f6b176 --- /dev/null +++ b/src/render/csv.ts @@ -0,0 +1,29 @@ +/** + * Renders an RFC-4180-style CSV string. The first line is the header row; + * subsequent lines are data rows. Every line, including the last, ends with + * `\n`. No external dependencies; output is fully deterministic. + */ +export function renderCsv(headers: string[], rows: unknown[][]): string { + const serializeCell = (value: unknown): string => { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); + } + // object, array, or anything else + return JSON.stringify(value); + }; + + const quoteCell = (text: string): string => { + if (/[,"\r\n]/.test(text)) { + return `"${text.replaceAll('"', '""')}"`; + } + return text; + }; + + const renderRow = (cells: unknown[]): string => + headers.map((_, col) => quoteCell(serializeCell(cells[col]))).join(","); + + const lines = [renderRow(headers), ...rows.map((row) => renderRow(row))]; + return `${lines.join("\n")}\n`; +} diff --git a/tests/render/csv.test.ts b/tests/render/csv.test.ts new file mode 100644 index 0000000..fbaf16a --- /dev/null +++ b/tests/render/csv.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { renderCsv } from "../../src/render/csv.js"; + +describe("renderCsv", () => { + it("renders header + simple rows with trailing newline", () => { + const out = renderCsv( + ["name", "age"], + [ + ["Alice", "30"], + ["Bob", "25"], + ], + ); + expect(out).toBe("name,age\nAlice,30\nBob,25\n"); + }); + + it("quotes a cell that contains a comma", () => { + const out = renderCsv(["city"], [["Portland, OR"]]); + expect(out).toBe('city\n"Portland, OR"\n'); + }); + + it("quotes a cell with a double quote and doubles the inner quote", () => { + const out = renderCsv(["quote"], [['he said "hi"']]); + expect(out).toBe('quote\n"he said ""hi"""\n'); + }); + + it("quotes a cell that contains a newline", () => { + const out = renderCsv(["notes"], [["line1\nline2"]]); + expect(out).toBe('notes\n"line1\nline2"\n'); + }); + + it("quotes a cell that contains a carriage return", () => { + const out = renderCsv(["notes"], [["line1\rline2"]]); + expect(out).toBe('notes\n"line1\rline2"\n'); + }); + + it("serializes null and undefined cells as empty strings", () => { + const out = renderCsv(["a", "b", "c"], [[null, undefined, "ok"]]); + expect(out).toBe("a,b,c\n,,ok\n"); + }); + + it("serializes an object cell via JSON.stringify and quotes when JSON has commas", () => { + const out = renderCsv(["obj"], [[{ x: 1, y: 2 }]]); + // JSON.stringify({x:1,y:2}) = '{"x":1,"y":2}' which contains a comma + expect(out).toBe('obj\n"{""x"":1,""y"":2}"\n'); + }); + + it("serializes number and boolean cells without quoting", () => { + const out = renderCsv(["n", "b"], [[42, true]]); + expect(out).toBe("n,b\n42,true\n"); + }); + + it("emits only the header line when rows is empty", () => { + const out = renderCsv(["col1", "col2"], []); + expect(out).toBe("col1,col2\n"); + }); +}); From 0e1f2f7649b2e626d9a675f8595bfe319393013d Mon Sep 17 00:00:00 2001 From: Hao Yan Date: Sun, 28 Jun 2026 18:35:17 -0600 Subject: [PATCH 3/4] feat(sql): add sql query command --- src/commands/index.ts | 2 + src/commands/sql.ts | 13 +++ src/commands/sql/query.ts | 195 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 src/commands/sql.ts create mode 100644 src/commands/sql/query.ts diff --git a/src/commands/index.ts b/src/commands/index.ts index 48d71c6..a940645 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -3,6 +3,7 @@ import { registerDoctor } from "./doctor.js"; import { registerInstrument } from "./instrument.js"; import { registerLogin } from "./login.js"; import { registerSkills } from "./skills.js"; +import { registerSql } from "./sql.js"; import { registerStatus } from "./status.js"; import { registerTraces } from "./traces.js"; @@ -14,6 +15,7 @@ export function registerCommands(program: Command): void { registerLogin(program); registerStatus(program); registerTraces(program); + registerSql(program); registerSkills(program); registerInstrument(program); registerDoctor(program); diff --git a/src/commands/sql.ts b/src/commands/sql.ts new file mode 100644 index 0000000..ee373fe --- /dev/null +++ b/src/commands/sql.ts @@ -0,0 +1,13 @@ +import type { Command } from "commander"; +import { registerSqlQuery } from "./sql/query.js"; + +export function registerSql(program: Command): void { + const sql = program + .command("sql") + .description("Run read-only SQL against your TraceRoot analytical export") + .helpCommand(false); + // NOTE: registerSqlSchema(sql) will be inserted here by the schema task, + // immediately above registerSqlQuery, so `traceroot sql schema` resolves + // as a subcommand before the default query action is evaluated. + registerSqlQuery(sql); +} diff --git a/src/commands/sql/query.ts b/src/commands/sql/query.ts new file mode 100644 index 0000000..109404e --- /dev/null +++ b/src/commands/sql/query.ts @@ -0,0 +1,195 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import type { Command } from "commander"; +import type { ApiClient, SqlQueryRequest } from "../../api/client.js"; +import { CliError, type Writers, defaultWriters, logProgress, logWarn } from "../../output.js"; +import { renderCsv } from "../../render/csv.js"; +import { createStyler } from "../../render/style.js"; +import { renderTable } from "../../render/table.js"; +import { contextFromCommand, requireApiClient } from "../shared.js"; + +export type SqlFormat = "table" | "json" | "csv"; + +const SQL_HELP_EXAMPLES = ` +Examples: + # count spans in the last 24h + traceroot sql "SELECT count() AS spans_24h FROM spans WHERE span_start_time >= now() - INTERVAL 24 HOUR" + + # p95 latency by model + traceroot sql "SELECT model_name, quantile(0.95)(duration_ms) AS p95_ms FROM spans WHERE model_name IS NOT NULL GROUP BY model_name ORDER BY p95_ms DESC" + + # cost by model + traceroot sql "SELECT model_name, sum(cost) AS total_cost FROM spans GROUP BY model_name ORDER BY total_cost DESC" + + # export spans to CSV + traceroot sql "SELECT * FROM spans WHERE span_start_time >= now() - INTERVAL 7 DAY" --csv --output spans.csv + + # find error spans + traceroot sql "SELECT span_id, name, status_message FROM spans WHERE status = 'ERROR' ORDER BY span_start_time DESC LIMIT 100" + + # show the analytical export schema + traceroot sql schema + +Output modes: default table | --json (one JSON line) | --csv (RFC-4180). --output writes any mode to a file.`; + +/** + * Exactly one of positional/file. Reads the file (utf8) when given. + * Throws CliError otherwise. + */ +export function resolveQuery(input: { positional?: string; file?: string }): string { + const { positional, file } = input; + if (positional === undefined && file === undefined) { + throw new CliError("provide a query argument or --file"); + } + if (positional !== undefined && file !== undefined) { + throw new CliError("provide either a query argument or --file, not both"); + } + let query: string; + if (file !== undefined) { + try { + query = readFileSync(file, "utf8"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new CliError(`could not read query file ${file}: ${message}`); + } + } else { + query = positional as string; + } + if (query.trim().length === 0) { + throw new CliError("the query is empty"); + } + return query; +} + +/** --json and --csv are mutually exclusive. Default is table. */ +export function resolveFormat(opts: { json: boolean; csv: boolean }): SqlFormat { + if (opts.json && opts.csv) { + throw new CliError("--json and --csv cannot be combined"); + } + if (opts.csv) { + return "csv"; + } + if (opts.json) { + return "json"; + } + return "table"; +} + +/** + * Positive-integer parse for --max-rows; undefined when absent. Mirror + * parseLimit in traces/list.ts. + */ +export function parseMaxRows(raw: string | undefined): number | undefined { + if (raw === undefined) { + return undefined; + } + if (!/^\d+$/.test(raw)) { + throw new CliError("--max-rows must be a positive integer"); + } + const value = Number.parseInt(raw, 10); + if (!Number.isInteger(value) || value < 1) { + throw new CliError("--max-rows must be a positive integer"); + } + return value; +} + +export interface RunSqlQueryDeps { + client: ApiClient; + query: string; + format: SqlFormat; + maxRows?: number; + outputPath?: string; + writers: Writers; +} + +/** Converts a single cell value to a string for table rendering. */ +function cellToString(cell: unknown): string { + if (cell === null || cell === undefined) { + return ""; + } + if (typeof cell === "string") { + return cell; + } + if (typeof cell === "object") { + return JSON.stringify(cell); + } + return String(cell); +} + +/** Core logic for `sql` query. Tests inject a fake client. */ +export async function runSqlQuery(deps: RunSqlQueryDeps): Promise { + const { client, query, format, maxRows, outputPath, writers } = deps; + + // 1. Build request body. + const body: SqlQueryRequest = { query }; + if (maxRows !== undefined) { + body.max_rows = maxRows; + } + + // 2. Execute the query. + const res = await client.sqlQuery(body); + + // 3. Build output string by format. + let payload: string; + if (format === "json") { + payload = `${JSON.stringify(res)}\n`; + } else if (format === "csv") { + payload = renderCsv( + res.columns.map((c) => c.name), + res.rows, + ); + } else { + // table + const rendered = renderTable( + res.columns.map((c) => c.name), + res.rows.map((row) => row.map(cellToString)), + { headerStyle: createStyler(writers.out).bold }, + ); + payload = `${rendered}\n`; + } + + // 4. Truncation warning (table and csv modes only; json is self-describing). + if (format !== "json" && res.truncated) { + logWarn( + `result truncated to ${res.row_count} row(s); add a LIMIT to the query or raise --max-rows to see more`, + writers, + ); + } + + // 5. Output routing. + if (outputPath !== undefined) { + writeFileSync(outputPath, payload, "utf8"); + logProgress(`wrote ${format} output to ${outputPath}`, writers); + } else { + writers.out.write(payload); + } +} + +export function registerSqlQuery(sql: Command): void { + sql + .argument("[query]", "SQL query string (omit when using --file)") + .option("-f, --file ", "read the query from a file instead of the positional arg") + .option("--csv", "emit CSV output") + .option("--output ", "write output to a file instead of stdout") + .option("--max-rows ", "maximum number of rows to return (positive integer)") + .addHelpText("after", SQL_HELP_EXAMPLES) + .action(async (query: string | undefined, _opts, command: Command) => { + const opts = command.optsWithGlobals(); + // Validate/resolve BEFORE requiring auth so arg errors surface without credentials. + const queryText = resolveQuery({ + positional: query, + file: opts.file as string | undefined, + }); + const format = resolveFormat({ json: opts.json === true, csv: opts.csv === true }); + const maxRows = parseMaxRows(opts.maxRows as string | undefined); + const ctx = contextFromCommand(command); + const client = requireApiClient(ctx); + await runSqlQuery({ + client, + query: queryText, + format, + maxRows, + outputPath: opts.output as string | undefined, + writers: defaultWriters, + }); + }); +} From b6d7e18a67866b4375feaea2ce8c976fbd8ff774 Mon Sep 17 00:00:00 2001 From: Hao Yan Date: Sun, 28 Jun 2026 18:35:17 -0600 Subject: [PATCH 4/4] test(sql): cover sql api client and query command --- tests/commands.register.test.ts | 6 + tests/commands/sql-query.test.ts | 266 +++++++++++++++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 tests/commands/sql-query.test.ts diff --git a/tests/commands.register.test.ts b/tests/commands.register.test.ts index 133860e..442c8e3 100644 --- a/tests/commands.register.test.ts +++ b/tests/commands.register.test.ts @@ -46,4 +46,10 @@ describe("buildProgram", () => { expect(subNames).toContain("list"); expect(subNames).toContain("install"); }); + + it("registers a sql group", () => { + const program = buildProgram(); + const names = childNames(program); + expect(names).toContain("sql"); + }); }); diff --git a/tests/commands/sql-query.test.ts b/tests/commands/sql-query.test.ts new file mode 100644 index 0000000..8930abe --- /dev/null +++ b/tests/commands/sql-query.test.ts @@ -0,0 +1,266 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { + ApiClient, + SqlQueryRequest, + SqlQueryResponse, + SqlSchemaResponse, +} from "../../src/api/client.js"; +import { + parseMaxRows, + resolveFormat, + resolveQuery, + runSqlQuery, +} from "../../src/commands/sql/query.js"; +import { CliError, type Writers } from "../../src/output.js"; +import { runCli } from "../helpers/runCli.js"; +import { StringSink } from "../helpers/stringSink.js"; + +function makeWriters(): { writers: Writers; out: StringSink; err: StringSink } { + const out = new StringSink(); + const err = new StringSink(); + return { writers: { out, err }, out, err }; +} + +const BASE_RESPONSE: SqlQueryResponse = { + columns: [ + { name: "model_name", type: "String" }, + { name: "n", type: "UInt64" }, + ], + rows: [ + ["gpt-4,turbo", 42], // comma → CSV quoting required + ['he said "hello"', 7], // quote → CSV double-escape required + ], + row_count: 2, + truncated: false, + elapsed_ms: 10, + statistics: {}, +}; + +function makeSqlClient(opts: { + queryResponse?: SqlQueryResponse; + queryError?: unknown; + schemaResponse?: SqlSchemaResponse; +}): { client: ApiClient; calls: { sqlQuery: SqlQueryRequest[] } } { + const calls = { sqlQuery: [] as SqlQueryRequest[] }; + const client: ApiClient = { + whoami: () => Promise.reject(new Error("not used")), + listTraces: () => Promise.reject(new Error("not used")), + getTrace: () => Promise.reject(new Error("not used")), + exportTrace: () => Promise.reject(new Error("not used")), + sqlQuery: (body) => { + calls.sqlQuery.push(body); + if (opts.queryError) return Promise.reject(opts.queryError); + return Promise.resolve(opts.queryResponse as SqlQueryResponse); + }, + sqlSchema: () => Promise.resolve(opts.schemaResponse as SqlSchemaResponse), + }; + return { client, calls }; +} + +let tmpRoot: string; + +beforeEach(() => { + tmpRoot = mkdtempSync(join(tmpdir(), "tr-sql-query-")); +}); + +afterEach(() => { + rmSync(tmpRoot, { recursive: true, force: true }); +}); + +describe("runSqlQuery", () => { + it("table output writes aligned table with headers and data to out; nothing is JSON", async () => { + const { client } = makeSqlClient({ queryResponse: BASE_RESPONSE }); + const { writers, out } = makeWriters(); + + await runSqlQuery({ client, query: "SELECT 1", format: "table", writers }); + + expect(out.data).toContain("model_name"); + expect(out.data).toContain("n"); + expect(out.data).toContain("gpt-4,turbo"); + // Sanity: table mode never emits the JSON "columns" key + expect(out.data).not.toContain('"columns"'); + }); + + it("json output writes exactly one compact JSON line that deep-equals the response", async () => { + const { client } = makeSqlClient({ queryResponse: BASE_RESPONSE }); + const { writers, out } = makeWriters(); + + await runSqlQuery({ client, query: "SELECT 1", format: "json", writers }); + + const lines = out.data.trimEnd().split("\n"); + expect(lines).toHaveLength(1); + expect(JSON.parse(out.data)).toEqual(BASE_RESPONSE); + }); + + it("csv output writes a header line and correctly quoted cells with trailing newline", async () => { + const { client } = makeSqlClient({ queryResponse: BASE_RESPONSE }); + const { writers, out } = makeWriters(); + + await runSqlQuery({ client, query: "SELECT 1", format: "csv", writers }); + + expect(out.data.startsWith("model_name,n\n")).toBe(true); + expect(out.data).toContain('"gpt-4,turbo"'); // comma in value → quoted field + expect(out.data).toContain('"he said ""hello"""'); // quote in value → doubled inside quotes + expect(out.data.endsWith("\n")).toBe(true); + }); + + it("output file write stores the formatted bytes and out stays empty", async () => { + const { client } = makeSqlClient({ queryResponse: BASE_RESPONSE }); + const { writers, out } = makeWriters(); + const outputPath = join(tmpRoot, "result.csv"); + + await runSqlQuery({ client, query: "SELECT 1", format: "csv", outputPath, writers }); + + const contents = readFileSync(outputPath, "utf8"); + expect(contents).toContain("model_name,n"); + expect(out.data).toBe(""); + }); + + it("truncation warning appears in err for table mode", async () => { + const truncated: SqlQueryResponse = { ...BASE_RESPONSE, truncated: true }; + const { client } = makeSqlClient({ queryResponse: truncated }); + const { writers, err } = makeWriters(); + + await runSqlQuery({ client, query: "SELECT 1", format: "table", writers }); + + expect(err.data).toContain("truncated"); + }); + + it("truncation warning appears in err for csv mode", async () => { + const truncated: SqlQueryResponse = { ...BASE_RESPONSE, truncated: true }; + const { client } = makeSqlClient({ queryResponse: truncated }); + const { writers, err } = makeWriters(); + + await runSqlQuery({ client, query: "SELECT 1", format: "csv", writers }); + + expect(err.data).toContain("truncated"); + }); + + it("no truncation warning in err when format is json, even when truncated:true", async () => { + const truncated: SqlQueryResponse = { ...BASE_RESPONSE, truncated: true }; + const { client } = makeSqlClient({ queryResponse: truncated }); + const { writers, err } = makeWriters(); + + await runSqlQuery({ client, query: "SELECT 1", format: "json", writers }); + + expect(err.data).not.toContain("truncated"); + }); + + it("max-rows value is forwarded in the request body", async () => { + const { client, calls } = makeSqlClient({ queryResponse: BASE_RESPONSE }); + const { writers } = makeWriters(); + + await runSqlQuery({ client, query: "SELECT 1", format: "table", maxRows: 1000, writers }); + + expect(calls.sqlQuery[0]?.max_rows).toBe(1000); + }); + + it("CliError from client.sqlQuery propagates and message contains the detail", async () => { + const detail = "Unknown table 'users'. Allowed tables: spans, traces."; + const { client } = makeSqlClient({ queryError: new CliError(detail) }); + const { writers } = makeWriters(); + + await expect( + runSqlQuery({ client, query: "SELECT * FROM users", format: "table", writers }), + ).rejects.toBeInstanceOf(CliError); + + await expect( + runSqlQuery({ client, query: "SELECT * FROM users", format: "table", writers }), + ).rejects.toThrow(detail); + }); + + it("table mode renders an object cell via JSON.stringify", async () => { + const objectResponse: SqlQueryResponse = { + ...BASE_RESPONSE, + columns: [{ name: "data", type: "String" }], + rows: [[{ a: 1 }]], + row_count: 1, + }; + const { client } = makeSqlClient({ queryResponse: objectResponse }); + const { writers, out } = makeWriters(); + + await runSqlQuery({ client, query: "SELECT 1", format: "table", writers }); + + expect(out.data).toContain('{"a":1}'); + }); +}); + +describe("resolveQuery", () => { + it("returns the positional query string as-is", () => { + expect(resolveQuery({ positional: "SELECT 1" })).toBe("SELECT 1"); + }); + + it("reads and returns the UTF-8 contents of a .sql file", () => { + const sqlFile = join(tmpRoot, "query.sql"); + writeFileSync(sqlFile, "SELECT count() FROM spans", "utf8"); + + expect(resolveQuery({ file: sqlFile })).toBe("SELECT count() FROM spans"); + }); + + it("throws CliError when both positional and file are provided", () => { + expect(() => resolveQuery({ positional: "SELECT 1", file: "query.sql" })).toThrow(CliError); + }); + + it("throws CliError when neither positional nor file is provided", () => { + expect(() => resolveQuery({})).toThrow(CliError); + }); + + it("throws CliError with 'the query is empty' when positional is whitespace-only", () => { + expect(() => resolveQuery({ positional: " " })).toThrow(CliError); + expect(() => resolveQuery({ positional: " " })).toThrow("the query is empty"); + }); + + it("throws CliError starting with 'could not read query file' when file does not exist", () => { + const missing = join(tmpRoot, "no-such-file.sql"); + expect(() => resolveQuery({ file: missing })).toThrow(CliError); + expect(() => resolveQuery({ file: missing })).toThrow("could not read query file"); + }); +}); + +describe("resolveFormat", () => { + it("returns 'table' when neither json nor csv is set", () => { + expect(resolveFormat({ json: false, csv: false })).toBe("table"); + }); + + it("returns 'json' when json is true", () => { + expect(resolveFormat({ json: true, csv: false })).toBe("json"); + }); + + it("returns 'csv' when csv is true", () => { + expect(resolveFormat({ json: false, csv: true })).toBe("csv"); + }); + + it("throws CliError when both json and csv are true", () => { + expect(() => resolveFormat({ json: true, csv: true })).toThrow(CliError); + }); +}); + +describe("parseMaxRows", () => { + it("returns undefined when raw is undefined", () => { + expect(parseMaxRows(undefined)).toBeUndefined(); + }); + + it("parses a valid positive integer string", () => { + expect(parseMaxRows("1000")).toBe(1000); + }); + + it("throws CliError for zero", () => { + expect(() => parseMaxRows("0")).toThrow(CliError); + }); + + it("throws CliError for a non-integer string", () => { + expect(() => parseMaxRows("x")).toThrow(CliError); + }); +}); + +describe("sql runCli surface", () => { + it("exits non-zero and stderr mentions the unknown option when --nope is passed", () => { + const { status, stderr } = runCli("sql", "SELECT 1", "--nope"); + + expect(status).not.toBe(0); + expect(stderr).toContain("--nope"); + }); +});