From 2cd8f52f4e21f3a29a55847be7378eb35b13b815 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 11 Apr 2026 08:57:58 +0200 Subject: [PATCH] feat(tools): added SQLite database operations with read/write support and query validation - Added SQLite path parsing and candidate validation for `.sqlite`, `.db`, `.db3`, and `.sqlite3` targets in read/write flows. - Added SQLite read operations for table lists, schema views, row lookups, paginated queries, and raw SELECT mode. - Added SQLite write operations for insert, update-by-key, and delete-by-key using JSON5 row payloads. - Updated selector routing to validate SQLite headers and fall back to normal file reads/writes when not databases. - Secured read mode by enforcing query validation to block destructive SQL execution on SQLite inputs. --- packages/coding-agent/CHANGELOG.md | 12 +- .../src/prompts/tools/read-chunk.md | 9 + .../coding-agent/src/prompts/tools/read.md | 9 + .../coding-agent/src/prompts/tools/write.md | 1 + packages/coding-agent/src/tools/read.ts | 219 +++++- .../coding-agent/src/tools/sqlite-reader.ts | 623 ++++++++++++++++++ packages/coding-agent/src/tools/write.ts | 188 +++++- .../coding-agent/test/tools/sqlite.test.ts | 403 +++++++++++ 8 files changed, 1461 insertions(+), 3 deletions(-) create mode 100644 packages/coding-agent/src/tools/sqlite-reader.ts create mode 100644 packages/coding-agent/test/tools/sqlite.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0c8f30962..e8244a3d0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog ## [Unreleased] + ### Added +- Added SQLite database read support to the `read` tool for `.sqlite`, `.sqlite3`, `.db`, and `.db3` files with table listing, schema + sample output, row lookup, paginated query filtering, and read-only `q=SELECT` mode +- Added SQLite mutation support to the `write` tool so `db.sqlite:table` inserts JSON5 rows and `db.sqlite:table:key` updates or deletes rows via row key - Added rendering of usage report entries for accounts with no usage limits, including account label and optional plan type with a `-- no limits` indicator - Updated account label resolution to fall back to email or accountId so unlabeled unlimited-plan accounts display a meaningful name - Added canonical model equivalence and provider coalescing across `models.yml`, `enabledModels`, `--models`, `/model`, and `--list-models` @@ -19,11 +22,18 @@ ### Fixed +- Fixed `.db`/`.db3` reads to verify SQLite file headers and fall back to normal file reading when the extension matches but the content is not a SQLite database +- Fixed SQLite selector parsing and resolution to correctly route requests to database operations at the file-extension boundary instead of misrouting through plain file/archive handlers +- Fixed unsupported or unsafe selectors by rejecting missing tables, composite primary keys for row lookups, unknown query parameters, and row operations on non-existent tables - Fixed model resolution for commit message generation, title generation, memory consolidation, and image inspection when role strings use canonical ids instead of raw provider/model values - Fixed default-model updates so previously configured thinking levels were preserved when reassigning a role - Fixed model scope and selection handling in CLI/session startup paths that previously failed to resolve aliases consistently across features - Fixed short-lived git subprocesses to disable `core.fsmonitor` and `core.untrackedCache`, avoiding unnecessary repository watchers and cache work during agent git operations +### Security + +- Blocked destructive SQL execution in read-mode SQLite access by using read-only connections and rejecting bound-parameter raw SQL + ## [14.0.5] - 2026-04-11 ### Added @@ -6984,4 +6994,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections +- HTML export with syntax highlighting and collapsible sections \ No newline at end of file diff --git a/packages/coding-agent/src/prompts/tools/read-chunk.md b/packages/coding-agent/src/prompts/tools/read-chunk.md index cd073bf7c..eaab05edd 100644 --- a/packages/coding-agent/src/prompts/tools/read-chunk.md +++ b/packages/coding-agent/src/prompts/tools/read-chunk.md @@ -18,6 +18,15 @@ Chunk reads preserve literal leading tabs/spaces from the file. When editing, ke {{/if}} Chunk trees: JS, TS, TSX, Python, Rust, Go. Others use blank-line fallback. + +# SQLite Databases +When used against a SQLite database (`.sqlite`, `.sqlite3`, `.db`, `.db3`), returns structured database content. +- `file.db` — list tables with row counts +- `file.db:table` — table schema + sample rows +- `file.db:table:key` — single row by primary key +- `file.db:table?limit=50&offset=100` — paginated rows +- `file.db:table?where=status='active'&order=created:desc` — filtered rows +- `file.db?q=SELECT ...` — read-only SELECT query diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index 3953b3f48..09f11482c 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -38,6 +38,15 @@ When used against a directory, or an archive root, the tool will return a list o - Formats: `.tar`, `.tar.gz`, `.tgz`, and `.zip`. - Use `archive.ext:path/inside/archive` to read or list archive contents +# SQLite Databases +When used against a SQLite database (`.sqlite`, `.sqlite3`, `.db`, `.db3`), returns structured database content. +- `file.db` — list tables with row counts +- `file.db:table` — table schema + sample rows +- `file.db:table:key` — single row by primary key +- `file.db:table?limit=50&offset=100` — paginated rows +- `file.db:table?where=status='active'&order=created:desc` — filtered rows +- `file.db?q=SELECT ...` — read-only SELECT query + # URLs - Extract information from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, technical blogs, RSS/Atom feeds, JSON endpoints - `sel="raw"` for untouched HTML or debugging diff --git a/packages/coding-agent/src/prompts/tools/write.md b/packages/coding-agent/src/prompts/tools/write.md index d592e5f2d..9576a790d 100644 --- a/packages/coding-agent/src/prompts/tools/write.md +++ b/packages/coding-agent/src/prompts/tools/write.md @@ -4,6 +4,7 @@ Creates or overwrites file at specified path. - Creating new files explicitly required by task - Replacing entire file contents when editing would be more complex - Supports `.tar`, `.tar.gz`, `.tgz`, and `.zip` archive entries via `archive.ext:path/inside/archive` +- Supports SQLite row operations via `db.sqlite:table` (insert), `db.sqlite:table:key` (update with JSON content, delete with empty content) diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index cb40f48cc..cd5a1a9be 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -1,3 +1,4 @@ +import { Database } from "bun:sqlite"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; @@ -38,7 +39,6 @@ import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading"; import { convertFileWithMarkit } from "../utils/markit"; import { type ArchiveReader, openArchive, parseArchivePathCandidates } from "./archive-reader"; - import { executeReadUrl, isReadableUrlPath, @@ -52,6 +52,22 @@ import { applyListLimit } from "./list-limit"; import { formatFullOutputReference, formatStyledTruncationWarning, type OutputMeta } from "./output-meta"; import { expandPath, resolveReadPath } from "./path-utils"; import { formatAge, formatBytes, shortenPath, wrapBrackets } from "./render-utils"; +import { + executeReadQuery, + getRowByKey, + getRowByRowId, + getTableSchema, + isSqliteFile, + listTables, + parseSqlitePathCandidates, + parseSqliteSelector, + queryRows, + renderRow, + renderSchema, + renderTable, + renderTableList, + resolveTableRowLookup, +} from "./sqlite-reader"; import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors"; import { toolResult } from "./tool-result"; @@ -420,6 +436,29 @@ interface ResolvedArchiveReadPath { suffixResolution?: { from: string; to: string }; } +interface ResolvedSqliteReadPath { + absolutePath: string; + sqliteSubPath: string; + queryString: string; + suffixResolution?: { from: string; to: string }; +} + +function parseSqliteSelectorInput(selector: string | undefined): { subPath: string; queryString: string } { + if (!selector) { + return { subPath: "", queryString: "" }; + } + + const queryIndex = selector.indexOf("?"); + if (queryIndex === -1) { + return { subPath: selector.replace(/^:+/, ""), queryString: "" }; + } + + return { + subPath: selector.slice(0, queryIndex).replace(/^:+/, ""), + queryString: selector.slice(queryIndex + 1), + }; +} + /** * Read tool implementation. * @@ -502,6 +541,53 @@ export class ReadTool implements AgentTool { return null; } + async #resolveSqliteReadPath(readPath: string, signal?: AbortSignal): Promise { + const candidates = parseSqlitePathCandidates(readPath); + for (const candidate of candidates) { + let absolutePath = resolveReadPath(candidate.sqlitePath, this.session.cwd); + let suffixResolution: { from: string; to: string } | undefined; + + try { + const stat = await Bun.file(absolutePath).stat(); + if (stat.isDirectory()) continue; + if (!(await isSqliteFile(absolutePath))) continue; + + return { + absolutePath, + sqliteSubPath: candidate.subPath, + queryString: candidate.queryString, + suffixResolution, + }; + } catch (error) { + if (!isNotFoundError(error) || isRemoteMountPath(absolutePath)) continue; + + const suffixMatch = await findUniqueSuffixMatch(candidate.sqlitePath, this.session.cwd, signal); + if (!suffixMatch) continue; + + try { + const retryStat = await Bun.file(suffixMatch.absolutePath).stat(); + if (retryStat.isDirectory()) continue; + if (!(await isSqliteFile(suffixMatch.absolutePath))) continue; + + absolutePath = suffixMatch.absolutePath; + suffixResolution = { from: candidate.sqlitePath, to: suffixMatch.displayPath }; + return { + absolutePath, + sqliteSubPath: candidate.subPath, + queryString: candidate.queryString, + suffixResolution, + }; + } catch (retryError) { + if (!isNotFoundError(retryError)) { + throw retryError; + } + } + } + } + + return null; + } + #buildInMemoryTextResult( text: string, offset: number | undefined, @@ -709,6 +795,132 @@ export class ReadTool implements AgentTool { return result; } + async #readSqlite( + sel: string | undefined, + resolvedSqlitePath: ResolvedSqliteReadPath, + signal?: AbortSignal, + ): Promise> { + throwIfAborted(signal); + + const selectorInput = sel + ? parseSqliteSelectorInput(sel) + : { subPath: resolvedSqlitePath.sqliteSubPath, queryString: resolvedSqlitePath.queryString }; + const selector = parseSqliteSelector(selectorInput.subPath, selectorInput.queryString); + const details: ReadToolDetails = { + resolvedPath: resolvedSqlitePath.absolutePath, + suffixResolution: resolvedSqlitePath.suffixResolution, + }; + + let db: Database | null = null; + try { + db = new Database(resolvedSqlitePath.absolutePath, { readonly: true, strict: true }); + db.run("PRAGMA busy_timeout = 3000"); + throwIfAborted(signal); + + switch (selector.kind) { + case "list": { + const listLimit = applyListLimit(listTables(db), { limit: 500 }); + const output = prependSuffixResolutionNotice( + renderTableList(listLimit.items), + resolvedSqlitePath.suffixResolution, + ); + const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER }); + details.truncation = truncation.truncated ? truncation : undefined; + const resultBuilder = toolResult(details) + .text(truncation.content) + .sourcePath(resolvedSqlitePath.absolutePath) + .limits({ resultLimit: listLimit.meta.resultLimit?.reached }); + if (truncation.truncated) { + resultBuilder.truncation(truncation, { direction: "head" }); + } + return resultBuilder.done(); + } + case "schema": { + const sampleRows = queryRows(db, selector.table, { limit: selector.sampleLimit, offset: 0 }); + let output = renderSchema(getTableSchema(db, selector.table), { + columns: sampleRows.columns, + rows: sampleRows.rows, + }); + if (sampleRows.rows.length < sampleRows.totalCount) { + const remaining = sampleRows.totalCount - sampleRows.rows.length; + output += `\n[${remaining} more rows; use sel="${selector.table}?limit=20&offset=${sampleRows.rows.length}" to continue]`; + } + return toolResult(details) + .text(prependSuffixResolutionNotice(output, resolvedSqlitePath.suffixResolution)) + .sourcePath(resolvedSqlitePath.absolutePath) + .done(); + } + case "row": { + const lookup = resolveTableRowLookup(db, selector.table); + const row = + lookup.kind === "pk" + ? getRowByKey(db, selector.table, lookup, selector.key) + : getRowByRowId(db, selector.table, selector.key); + if (!row) { + return toolResult(details) + .text( + prependSuffixResolutionNotice( + `No row found in table '${selector.table}' for key '${selector.key}'.`, + resolvedSqlitePath.suffixResolution, + ), + ) + .sourcePath(resolvedSqlitePath.absolutePath) + .done(); + } + return toolResult(details) + .text(prependSuffixResolutionNotice(renderRow(row), resolvedSqlitePath.suffixResolution)) + .sourcePath(resolvedSqlitePath.absolutePath) + .done(); + } + case "query": { + const page = queryRows(db, selector.table, selector); + return toolResult(details) + .text( + prependSuffixResolutionNotice( + renderTable(page.columns, page.rows, { + totalCount: page.totalCount, + offset: selector.offset, + limit: selector.limit, + table: selector.table, + dbPath: resolvedSqlitePath.absolutePath, + }), + resolvedSqlitePath.suffixResolution, + ), + ) + .sourcePath(resolvedSqlitePath.absolutePath) + .done(); + } + case "raw": { + const result = executeReadQuery(db, selector.sql); + return toolResult(details) + .text( + prependSuffixResolutionNotice( + renderTable(result.columns, result.rows, { + totalCount: result.rows.length, + offset: 0, + limit: result.rows.length || DEFAULT_MAX_LINES, + table: "query", + dbPath: resolvedSqlitePath.absolutePath, + }), + resolvedSqlitePath.suffixResolution, + ), + ) + .sourcePath(resolvedSqlitePath.absolutePath) + .done(); + } + } + + throw new ToolError("Unsupported SQLite selector"); + } catch (error) { + if (error instanceof ToolError) { + throw error; + } + throw new ToolError(error instanceof Error ? error.message : String(error)); + } finally { + db?.close(); + } + } + async execute( _toolCallId: string, params: ReadParams, @@ -769,6 +981,11 @@ export class ReadTool implements AgentTool { return this.#readArchive(readPath, offset, limit, archivePath, signal); } + const sqlitePath = await this.#resolveSqliteReadPath(readPath, signal); + if (sqlitePath) { + return this.#readSqlite(sel, sqlitePath, signal); + } + let absolutePath = resolveReadPath(localReadPath, this.session.cwd); let suffixResolution: { from: string; to: string } | undefined; diff --git a/packages/coding-agent/src/tools/sqlite-reader.ts b/packages/coding-agent/src/tools/sqlite-reader.ts new file mode 100644 index 000000000..b94adbf80 --- /dev/null +++ b/packages/coding-agent/src/tools/sqlite-reader.ts @@ -0,0 +1,623 @@ +import type { Database, SQLQueryBindings } from "bun:sqlite"; +import { formatBytes, replaceTabs, truncateToWidth } from "./render-utils"; +import { ToolError } from "./tool-errors"; + +const SQLITE_MAGIC = new Uint8Array([ + 0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x20, 0x33, 0x00, +]); +const SQLITE_PATH_PATTERN = /\.(?:sqlite3?|db3?)(?=(?::|\?|$))/gi; +const DEFAULT_QUERY_LIMIT = 20; +const DEFAULT_SCHEMA_SAMPLE_LIMIT = 5; +const MAX_QUERY_LIMIT = 500; +const MAX_RENDER_WIDTH = 120; +const MAX_COLUMN_WIDTH = 40; +const MIN_COLUMN_WIDTH = 1; + +type SqliteBinding = Exclude>; + +type SqliteRow = Record; + +interface SqliteMasterRow { + name: string; + sql: string | null; +} + +interface SqliteCountRow { + count: number; +} + +interface SqliteTableInfoRow { + cid: number; + name: string; + type: string; + notnull: number; + dflt_value: unknown; + pk: number; +} + +export interface SqlitePathCandidate { + sqlitePath: string; + subPath: string; + queryString: string; +} + +export type SqliteSelector = + | { kind: "list" } + | { kind: "schema"; table: string; sampleLimit: number } + | { kind: "row"; table: string; key: string } + | { kind: "query"; table: string; limit: number; offset: number; order?: string; where?: string } + | { kind: "raw"; sql: string }; + +export type SqliteRowLookup = { kind: "pk"; column: string; type: string } | { kind: "rowid" }; + +function splitSqliteRemainder(remainder: string): { subPath: string; queryString: string } { + const queryIndex = remainder.indexOf("?"); + if (queryIndex === -1) { + return { + subPath: remainder.replace(/^:+/, ""), + queryString: "", + }; + } + + return { + subPath: remainder.slice(0, queryIndex).replace(/^:+/, ""), + queryString: remainder.slice(queryIndex + 1), + }; +} + +function quoteSqliteIdentifier(identifier: string): string { + return `"${identifier.replaceAll('"', '""')}"`; +} + +function sanitizeCell(value: string): string { + return replaceTabs(value).replaceAll(/\r?\n/g, "\\n"); +} + +function stringifySqliteValue(value: unknown): string { + if (value === null) return "NULL"; + if (value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); + } + if (value instanceof Uint8Array) { + return ``; + } + + try { + const json = JSON.stringify(value); + return json ?? String(value); + } catch { + return String(value); + } +} + +function padCell(value: string, width: number): string { + const truncated = truncateToWidth(sanitizeCell(value), Math.max(width, MIN_COLUMN_WIDTH)); + const visibleWidth = Bun.stringWidth(truncated); + if (visibleWidth >= width) { + return truncated; + } + return `${truncated}${" ".repeat(width - visibleWidth)}`; +} + +function buildAsciiTable(columns: string[], rows: SqliteRow[]): string { + if (columns.length === 0) { + return rows.length === 0 ? "(no rows)" : "(rows returned without named columns)"; + } + + const widths = columns.map(column => + Math.max(MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, Bun.stringWidth(sanitizeCell(column)))), + ); + for (const row of rows) { + for (const [index, column] of columns.entries()) { + const cellWidth = Bun.stringWidth(sanitizeCell(stringifySqliteValue(row[column]))); + widths[index] = Math.max(widths[index] ?? MIN_COLUMN_WIDTH, Math.min(MAX_COLUMN_WIDTH, cellWidth)); + } + } + + let totalWidth = widths.reduce((sum, width) => sum + width, 0) + columns.length * 3 + 1; + while (totalWidth > MAX_RENDER_WIDTH) { + let widestIndex = -1; + let widestWidth = MIN_COLUMN_WIDTH; + for (const [index, width] of widths.entries()) { + if (width > widestWidth) { + widestIndex = index; + widestWidth = width; + } + } + if (widestIndex === -1) break; + widths[widestIndex] = Math.max(MIN_COLUMN_WIDTH, (widths[widestIndex] ?? MIN_COLUMN_WIDTH) - 1); + totalWidth = widths.reduce((sum, width) => sum + width, 0) + columns.length * 3 + 1; + } + + const header = `| ${columns.map((column, index) => padCell(column, widths[index] ?? MIN_COLUMN_WIDTH)).join(" | ")} |`; + const divider = `| ${widths.map(width => "-".repeat(Math.max(width, MIN_COLUMN_WIDTH))).join(" | ")} |`; + const lines = [header, divider]; + + if (rows.length === 0) { + lines.push("(no rows)"); + return lines.map(line => truncateToWidth(replaceTabs(line), MAX_RENDER_WIDTH)).join("\n"); + } + + for (const row of rows) { + const cells = columns.map((column, index) => + padCell(stringifySqliteValue(row[column]), widths[index] ?? MIN_COLUMN_WIDTH), + ); + lines.push(`| ${cells.join(" | ")} |`); + } + + return lines.map(line => truncateToWidth(replaceTabs(line), MAX_RENDER_WIDTH)).join("\n"); +} + +function parseLimit(value: string | null, fallback: number): number { + if (value === null || value.trim().length === 0) { + return fallback; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 1) { + throw new ToolError(`SQLite limit must be a positive integer; got '${value}'`); + } + return Math.min(parsed, MAX_QUERY_LIMIT); +} + +function parseOffset(value: string | null): number { + if (value === null || value.trim().length === 0) { + return 0; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new ToolError(`SQLite offset must be a non-negative integer; got '${value}'`); + } + return parsed; +} + +function getTableMasterRow(db: Database, table: string): SqliteMasterRow { + const row = + db + .prepare( + "SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name = ?", + ) + .get(table) ?? null; + if (!row) { + throw new ToolError(`SQLite table '${table}' not found`); + } + return row; +} + +function getTableInfoRows(db: Database, table: string): SqliteTableInfoRow[] { + getTableMasterRow(db, table); + return db.prepare(`PRAGMA table_info(${quoteSqliteIdentifier(table)})`).all(); +} + +function getTableColumns(db: Database, table: string): string[] { + return getTableInfoRows(db, table).map(column => column.name); +} + +function getPrimaryKeyColumns(db: Database, table: string): SqliteTableInfoRow[] { + return getTableInfoRows(db, table) + .filter(column => column.pk > 0) + .sort((left, right) => left.pk - right.pk); +} + +function coerceIntegerKey(key: string, label: string): number | bigint { + const trimmed = key.trim(); + if (!/^-?\d+$/.test(trimmed)) { + throw new ToolError(`${label} must be an integer; got '${key}'`); + } + + const asNumber = Number.parseInt(trimmed, 10); + if (Number.isSafeInteger(asNumber)) { + return asNumber; + } + return BigInt(trimmed); +} + +function coerceLookupValue(key: string, type: string): SqliteBinding { + const normalizedType = type.trim().toUpperCase(); + if (normalizedType.includes("INT")) { + return coerceIntegerKey(key, `Primary key '${key}'`); + } + if (normalizedType.includes("REAL") || normalizedType.includes("FLOA") || normalizedType.includes("DOUB")) { + const parsed = Number(key); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return key; +} + +function resolveOrderClause(order: string | undefined, columns: string[]): string { + if (!order) return ""; + const trimmed = order.trim(); + if (!trimmed) return ""; + + const separatorIndex = trimmed.lastIndexOf(":"); + const column = separatorIndex === -1 ? trimmed : trimmed.slice(0, separatorIndex); + const direction = + separatorIndex === -1 + ? "asc" + : trimmed + .slice(separatorIndex + 1) + .trim() + .toLowerCase(); + if (!columns.includes(column)) { + throw new ToolError(`SQLite order column '${column}' not found in table schema`); + } + if (direction !== "asc" && direction !== "desc") { + throw new ToolError(`SQLite order direction must be 'asc' or 'desc'; got '${direction}'`); + } + return ` ORDER BY ${quoteSqliteIdentifier(column)} ${direction.toUpperCase()}`; +} + +function normalizeWriteValue(value: unknown, column: string): SqliteBinding { + if (value === null) return null; + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return value; + } + throw new ToolError(`SQLite column '${column}' only accepts JSON scalar values or null`); +} + +function validateWriteColumns( + db: Database, + table: string, + data: Record, +): Array<[string, SqliteBinding]> { + const columns = new Set(getTableColumns(db, table)); + return Object.entries(data).map(([column, value]) => { + if (!columns.has(column)) { + throw new ToolError(`SQLite table '${table}' has no column named '${column}'`); + } + return [column, normalizeWriteValue(value, column)]; + }); +} + +export function parseSqlitePathCandidates(filePath: string): SqlitePathCandidate[] { + const normalized = filePath.replace(/\\/g, "/"); + const seen = new Set(); + const candidates: SqlitePathCandidate[] = []; + + let match: RegExpExecArray | null; + while (true) { + match = SQLITE_PATH_PATTERN.exec(normalized); + if (match === null) { + break; + } + + const end = match.index + match[0].length; + const sqlitePath = filePath.slice(0, end); + const remainder = normalized.slice(end); + const { subPath, queryString } = splitSqliteRemainder(remainder); + const key = `${sqlitePath}\0${subPath}\0${queryString}`; + if (seen.has(key)) continue; + seen.add(key); + candidates.push({ sqlitePath, subPath, queryString }); + } + + return candidates.sort((left, right) => right.sqlitePath.length - left.sqlitePath.length); +} + +export async function isSqliteFile(absolutePath: string): Promise { + try { + const bytes = await Bun.file(absolutePath).slice(0, SQLITE_MAGIC.byteLength).bytes(); + if (bytes.length !== SQLITE_MAGIC.byteLength) { + return false; + } + + for (const [index, byte] of SQLITE_MAGIC.entries()) { + if (bytes[index] !== byte) { + return false; + } + } + + return true; + } catch { + return false; + } +} + +export function parseSqliteSelector(subPath: string, queryString: string): SqliteSelector { + const normalizedSubPath = subPath.replace(/^:+/, "").trim(); + const params = new URLSearchParams(queryString); + const rawQuery = params.get("q"); + + if (rawQuery !== null) { + const otherKeys = [...params.keys()].filter(key => key !== "q"); + if (normalizedSubPath || otherKeys.length > 0) { + throw new ToolError("SQLite raw queries cannot be combined with table selectors or pagination"); + } + if (!rawQuery.trim()) { + throw new ToolError("SQLite query parameter 'q' cannot be empty"); + } + return { kind: "raw", sql: rawQuery }; + } + + if (!normalizedSubPath) { + if (params.size > 0) { + throw new ToolError("SQLite query parameters require a table selector or q=SELECT..."); + } + return { kind: "list" }; + } + + const separatorIndex = normalizedSubPath.indexOf(":"); + const table = separatorIndex === -1 ? normalizedSubPath : normalizedSubPath.slice(0, separatorIndex); + const key = separatorIndex === -1 ? undefined : normalizedSubPath.slice(separatorIndex + 1); + if (!table) { + throw new ToolError("SQLite selectors must include a table name"); + } + + if (key !== undefined && key.length > 0) { + if (params.size > 0) { + throw new ToolError("SQLite row lookups cannot be combined with query parameters"); + } + return { kind: "row", table, key }; + } + + const where = params.get("where")?.trim() || undefined; + const order = params.get("order")?.trim() || undefined; + const hasQueryParams = params.has("limit") || params.has("offset") || order !== undefined || where !== undefined; + if (hasQueryParams) { + const knownKeys = new Set(["limit", "offset", "order", "where"]); + for (const keyName of params.keys()) { + if (!knownKeys.has(keyName)) { + throw new ToolError(`Unsupported SQLite query parameter '${keyName}'`); + } + } + return { + kind: "query", + table, + limit: parseLimit(params.get("limit"), DEFAULT_QUERY_LIMIT), + offset: parseOffset(params.get("offset")), + order, + where, + }; + } + + if (params.size > 0) { + for (const keyName of params.keys()) { + throw new ToolError(`Unsupported SQLite query parameter '${keyName}'`); + } + } + + return { kind: "schema", table, sampleLimit: DEFAULT_SCHEMA_SAMPLE_LIMIT }; +} + +export function listTables(db: Database): { name: string; rowCount: number }[] { + const names = db + .prepare, []>( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name COLLATE NOCASE", + ) + .all(); + + return names.map(({ name }) => { + const countRow = + db.prepare(`SELECT COUNT(*) AS count FROM ${quoteSqliteIdentifier(name)}`).get() ?? null; + return { + name, + rowCount: countRow?.count ?? 0, + }; + }); +} + +export function getTableSchema(db: Database, table: string): string { + const row = getTableMasterRow(db, table); + if (!row.sql) { + throw new ToolError(`SQLite schema for table '${table}' is unavailable`); + } + return row.sql; +} + +export function getTablePrimaryKey(db: Database, table: string): { column: string; type: string } | null { + const primaryKeyColumns = getPrimaryKeyColumns(db, table); + if (primaryKeyColumns.length !== 1) { + return null; + } + + const column = primaryKeyColumns[0]!; + return { column: column.name, type: column.type }; +} + +export function resolveTableRowLookup(db: Database, table: string): SqliteRowLookup { + const primaryKeyColumns = getPrimaryKeyColumns(db, table); + if (primaryKeyColumns.length === 1) { + const column = primaryKeyColumns[0]!; + return { kind: "pk", column: column.name, type: column.type }; + } + if (primaryKeyColumns.length > 1) { + throw new ToolError(`SQLite table '${table}' has a composite primary key; use '?where=' instead`); + } + + const schema = getTableSchema(db, table); + if (/\bWITHOUT\s+ROWID\b/i.test(schema)) { + throw new ToolError(`SQLite table '${table}' does not expose ROWID; use '?where=' instead`); + } + + return { kind: "rowid" }; +} + +export function queryRows( + db: Database, + table: string, + opts: { limit: number; offset: number; order?: string; where?: string }, +): { columns: string[]; rows: Record[]; totalCount: number } { + const columns = getTableColumns(db, table); + const whereClause = opts.where?.trim() ? ` WHERE ${opts.where.trim()}` : ""; + const orderClause = resolveOrderClause(opts.order, columns); + const countSql = `SELECT COUNT(*) AS count FROM ${quoteSqliteIdentifier(table)}${whereClause}`; + const selectSql = `SELECT * FROM ${quoteSqliteIdentifier(table)}${whereClause}${orderClause} LIMIT ? OFFSET ?`; + const totalCount = db.prepare(countSql).get()?.count ?? 0; + const rows = db.prepare(selectSql).all(opts.limit, opts.offset); + return { columns, rows, totalCount }; +} + +export function getRowByKey( + db: Database, + table: string, + pk: { column: string; type?: string }, + key: string, +): Record | null { + getTableMasterRow(db, table); + const sql = `SELECT * FROM ${quoteSqliteIdentifier(table)} WHERE ${quoteSqliteIdentifier(pk.column)} = ? LIMIT 1`; + const binding = coerceLookupValue(key, pk.type ?? ""); + return db.prepare(sql).get(binding); +} + +export function getRowByRowId(db: Database, table: string, key: string): Record | null { + getTableMasterRow(db, table); + const binding = coerceIntegerKey(key, "SQLite ROWID"); + return db + .prepare(`SELECT * FROM ${quoteSqliteIdentifier(table)} WHERE rowid = ? LIMIT 1`) + .get(binding); +} + +export function executeReadQuery(db: Database, sql: string): { columns: string[]; rows: Record[] } { + const statement = db.prepare(sql); + if (statement.paramsCount > 0) { + throw new ToolError("SQLite raw queries do not support bound parameters"); + } + return { + columns: [...statement.columnNames], + rows: statement.all(), + }; +} + +export function insertRow(db: Database, table: string, data: Record): void { + getTableMasterRow(db, table); + const entries = validateWriteColumns(db, table, data); + if (entries.length === 0) { + db.run(`INSERT INTO ${quoteSqliteIdentifier(table)} DEFAULT VALUES`); + return; + } + + const columns = entries.map(([column]) => quoteSqliteIdentifier(column)).join(", "); + const placeholders = entries.map(() => "?").join(", "); + const bindings = entries.map(([, value]) => value); + const statement = db.prepare( + `INSERT INTO ${quoteSqliteIdentifier(table)} (${columns}) VALUES (${placeholders})`, + ); + statement.run(...bindings); +} + +export function updateRowByKey( + db: Database, + table: string, + pk: { column: string; type?: string }, + key: string, + data: Record, +): number { + getTableMasterRow(db, table); + const entries = validateWriteColumns(db, table, data); + if (entries.length === 0) { + throw new ToolError("SQLite updates require at least one column value"); + } + + const assignments = entries.map(([column]) => `${quoteSqliteIdentifier(column)} = ?`).join(", "); + const bindings = entries.map(([, value]) => value); + bindings.push(coerceLookupValue(key, pk.type ?? "")); + const statement = db.prepare( + `UPDATE ${quoteSqliteIdentifier(table)} SET ${assignments} WHERE ${quoteSqliteIdentifier(pk.column)} = ?`, + ); + return statement.run(...bindings).changes; +} + +export function updateRowByRowId(db: Database, table: string, key: string, data: Record): number { + getTableMasterRow(db, table); + const entries = validateWriteColumns(db, table, data); + if (entries.length === 0) { + throw new ToolError("SQLite updates require at least one column value"); + } + + const assignments = entries.map(([column]) => `${quoteSqliteIdentifier(column)} = ?`).join(", "); + const bindings = entries.map(([, value]) => value); + bindings.push(coerceIntegerKey(key, "SQLite ROWID")); + const statement = db.prepare( + `UPDATE ${quoteSqliteIdentifier(table)} SET ${assignments} WHERE rowid = ?`, + ); + return statement.run(...bindings).changes; +} + +export function deleteRowByKey( + db: Database, + table: string, + pk: { column: string; type?: string }, + key: string, +): number { + getTableMasterRow(db, table); + const binding = coerceLookupValue(key, pk.type ?? ""); + const statement = db.prepare( + `DELETE FROM ${quoteSqliteIdentifier(table)} WHERE ${quoteSqliteIdentifier(pk.column)} = ?`, + ); + return statement.run(binding).changes; +} + +export function deleteRowByRowId(db: Database, table: string, key: string): number { + getTableMasterRow(db, table); + const binding = coerceIntegerKey(key, "SQLite ROWID"); + const statement = db.prepare( + `DELETE FROM ${quoteSqliteIdentifier(table)} WHERE rowid = ?`, + ); + return statement.run(binding).changes; +} + +export function renderTableList(tables: { name: string; rowCount: number }[]): string { + if (tables.length === 0) { + return "(no tables)"; + } + + return tables + .map(table => truncateToWidth(replaceTabs(`${table.name} (${table.rowCount} rows)`), MAX_RENDER_WIDTH)) + .join("\n"); +} + +export function renderSchema( + createSql: string, + sampleRows: { columns: string[]; rows: Record[] }, +): string { + const schemaLines = replaceTabs(createSql) + .split("\n") + .map(line => truncateToWidth(line, MAX_RENDER_WIDTH)); + const parts = [schemaLines.join("\n"), "", "Sample rows:", buildAsciiTable(sampleRows.columns, sampleRows.rows)]; + return parts.join("\n"); +} + +export function renderRow(row: Record): string { + const entries = Object.entries(row); + if (entries.length === 0) { + return "(no columns)"; + } + + return entries + .map(([column, value]) => + truncateToWidth(replaceTabs(`${column}: ${stringifySqliteValue(value)}`), MAX_RENDER_WIDTH), + ) + .join("\n"); +} + +export function renderTable( + columns: string[], + rows: Record[], + meta: { totalCount: number; offset: number; limit: number; table: string; dbPath: string }, +): string { + const parts = [buildAsciiTable(columns, rows)]; + const shown = Math.min(meta.totalCount, meta.offset + rows.length); + if (shown < meta.totalCount) { + const remaining = meta.totalCount - shown; + const nextOffset = meta.offset + rows.length; + parts.push( + truncateToWidth( + replaceTabs( + `[${remaining} more rows; use sel="${meta.table}?limit=${meta.limit}&offset=${nextOffset}" to continue]`, + ), + MAX_RENDER_WIDTH, + ), + ); + } + return parts.join("\n"); +} diff --git a/packages/coding-agent/src/tools/write.ts b/packages/coding-agent/src/tools/write.ts index 71246d765..446d29883 100644 --- a/packages/coding-agent/src/tools/write.ts +++ b/packages/coding-agent/src/tools/write.ts @@ -1,3 +1,4 @@ +import { Database } from "bun:sqlite"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import type { @@ -9,7 +10,7 @@ import type { } from "@oh-my-pi/pi-agent-core"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text } from "@oh-my-pi/pi-tui"; -import { isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils"; +import { isEnoent, isRecord, prompt, untilAborted } from "@oh-my-pi/pi-utils"; import { type Static, Type } from "@sinclair/typebox"; import { unzipSync, zipSync } from "fflate"; import { stripHashlinePrefixes } from "../edit"; @@ -34,7 +35,18 @@ import { replaceTabs, shortenPath, } from "./render-utils"; +import { + deleteRowByKey, + deleteRowByRowId, + insertRow, + isSqliteFile, + parseSqlitePathCandidates, + resolveTableRowLookup, + updateRowByKey, + updateRowByRowId, +} from "./sqlite-reader"; import { ToolError } from "./tool-errors"; +import { toolResult } from "./tool-result"; const writeSchema = Type.Object({ path: Type.String({ description: "Path to the file to write (relative or absolute)" }), @@ -94,6 +106,14 @@ interface ResolvedArchiveWritePath { exists: boolean; } +interface ResolvedSqliteWritePath { + absolutePath: string; + sqlitePath: string; + table: string; + key?: string; + exists: boolean; +} + function isArchivePathNotFound(error: unknown): boolean { if (isEnoent(error)) return true; return typeof error === "object" && error !== null && "code" in error && error.code === "ENOTDIR"; @@ -125,6 +145,29 @@ function normalizeArchiveWriteSubPath(rawPath: string): string { return normalizedParts.join("/"); } +function parseSqliteWriteTarget(subPath: string, queryString: string): { table: string; key?: string } { + if (queryString.trim().length > 0) { + throw new ToolError("SQLite write paths do not support query parameters"); + } + + const normalized = subPath.replace(/^:+/, "").trim(); + if (!normalized) { + throw new ToolError("SQLite write path must target a table"); + } + + const separatorIndex = normalized.indexOf(":"); + const table = separatorIndex === -1 ? normalized : normalized.slice(0, separatorIndex); + const key = separatorIndex === -1 ? undefined : normalized.slice(separatorIndex + 1); + if (!table) { + throw new ToolError("SQLite write path must target a table"); + } + if (key !== undefined && key.length === 0) { + throw new ToolError("SQLite row writes require a non-empty row key"); + } + + return { table, key }; +} + /** * Write tool implementation. * @@ -262,6 +305,132 @@ export class WriteTool implements AgentTool { + const candidates = parseSqlitePathCandidates(writePath).filter(candidate => candidate.sqlitePath !== writePath); + if (candidates.length === 0) { + return null; + } + + const fallbackCandidate = candidates[candidates.length - 1]!; + const fallbackTarget = parseSqliteWriteTarget(fallbackCandidate.subPath, fallbackCandidate.queryString); + const fallback: ResolvedSqliteWritePath = { + absolutePath: resolvePlanPath(this.session, fallbackCandidate.sqlitePath), + sqlitePath: fallbackCandidate.sqlitePath, + table: fallbackTarget.table, + key: fallbackTarget.key, + exists: false, + }; + + let sawExistingNonSqlite = false; + for (const candidate of candidates) { + const target = parseSqliteWriteTarget(candidate.subPath, candidate.queryString); + const absolutePath = resolvePlanPath(this.session, candidate.sqlitePath); + try { + const stat = await Bun.file(absolutePath).stat(); + if (stat.isDirectory()) { + continue; + } + if (!(await isSqliteFile(absolutePath))) { + sawExistingNonSqlite = true; + continue; + } + + return { + absolutePath, + sqlitePath: candidate.sqlitePath, + table: target.table, + key: target.key, + exists: true, + }; + } catch (error) { + if (!isArchivePathNotFound(error)) { + throw error; + } + } + } + + if (sawExistingNonSqlite) { + return null; + } + + return fallback; + } + + async #writeSqliteRow( + displayPath: string, + content: string, + resolvedSqlitePath: ResolvedSqliteWritePath, + ): Promise> { + let db: Database | null = null; + try { + if (!resolvedSqlitePath.exists) { + throw new ToolError(`SQLite database '${displayPath}' not found`); + } + + db = new Database(resolvedSqlitePath.absolutePath, { create: false, strict: true }); + db.run("PRAGMA busy_timeout = 3000"); + + const trimmedContent = content.trim(); + let resultText: string; + if (trimmedContent.length === 0) { + if (!resolvedSqlitePath.key) { + throw new ToolError("SQLite deletes require a row key in the path"); + } + + const lookup = resolveTableRowLookup(db, resolvedSqlitePath.table); + const deleted = + lookup.kind === "pk" + ? deleteRowByKey(db, resolvedSqlitePath.table, lookup, resolvedSqlitePath.key) + : deleteRowByRowId(db, resolvedSqlitePath.table, resolvedSqlitePath.key); + resultText = + deleted > 0 + ? `Deleted row '${resolvedSqlitePath.key}' from ${resolvedSqlitePath.table}` + : `No row deleted from ${resolvedSqlitePath.table} for key '${resolvedSqlitePath.key}'`; + } else { + let parsedContent: unknown; + try { + parsedContent = Bun.JSON5.parse(content); + } catch (error) { + throw new ToolError( + `SQLite write content must be valid JSON5: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + if (!isRecord(parsedContent)) { + throw new ToolError("SQLite write content must be a JSON object"); + } + + if (resolvedSqlitePath.key) { + const lookup = resolveTableRowLookup(db, resolvedSqlitePath.table); + const updated = + lookup.kind === "pk" + ? updateRowByKey(db, resolvedSqlitePath.table, lookup, resolvedSqlitePath.key, parsedContent) + : updateRowByRowId(db, resolvedSqlitePath.table, resolvedSqlitePath.key, parsedContent); + resultText = + updated > 0 + ? `Updated row '${resolvedSqlitePath.key}' in ${resolvedSqlitePath.table}` + : `No row updated in ${resolvedSqlitePath.table} for key '${resolvedSqlitePath.key}'`; + } else { + insertRow(db, resolvedSqlitePath.table, parsedContent); + resultText = `Inserted row into ${resolvedSqlitePath.table}`; + } + } + + invalidateFsScanAfterWrite(resolvedSqlitePath.absolutePath); + return toolResult({}).text(resultText).sourcePath(resolvedSqlitePath.absolutePath).done(); + } catch (error) { + if (isEnoent(error)) { + throw new ToolError(`SQLite database '${displayPath}' not found`); + } + if (error instanceof ToolError) { + throw error; + } + throw new ToolError(error instanceof Error ? error.message : String(error)); + } finally { + db?.close(); + } + } + async execute( _toolCallId: string, { path, content }: WriteParams, @@ -291,6 +460,23 @@ export class WriteTool implements AgentTool + block.type === "text" && typeof block.text === "string", + ); + if (firstText) { + firstText.text += `\nNote: auto-stripped hashline display prefixes from content before writing.`; + } + } + return sqliteResult; + } + enforcePlanModeWrite(this.session, path, { op: "create" }); const absolutePath = resolvePlanPath(this.session, path); const batchRequest = getLspBatchRequest(context?.toolCall); diff --git a/packages/coding-agent/test/tools/sqlite.test.ts b/packages/coding-agent/test/tools/sqlite.test.ts new file mode 100644 index 000000000..f72ed8d2d --- /dev/null +++ b/packages/coding-agent/test/tools/sqlite.test.ts @@ -0,0 +1,403 @@ +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import "../../src/tools/renderers"; +import { Settings } from "../../src/config/settings"; +import { ReadTool } from "../../src/tools/read"; +import { parseSqlitePathCandidates, parseSqliteSelector, renderTable } from "../../src/tools/sqlite-reader"; +import { WriteTool } from "../../src/tools/write"; + +type ToolTextResult = { + content: Array<{ type: string; text?: string }>; +}; + +type SessionLike = ConstructorParameters[0]; + +function getText(result: ToolTextResult): string { + return result.content + .filter(block => block.type === "text") + .map(block => block.text ?? "") + .join("\n"); +} + +function createSession(cwd: string, overrides: Partial = {}): SessionLike { + return { + cwd, + hasUI: false, + enableLsp: false, + getSessionFile: () => null, + getSessionSpawns: () => "*", + settings: Settings.isolated(), + ...overrides, + } as SessionLike; +} + +function createFixtureDatabase(dbPath: string): void { + const db = new Database(dbPath); + try { + db.run(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL, + status TEXT NOT NULL, + created INTEGER NOT NULL + ); + CREATE TABLE slugs ( + slug TEXT PRIMARY KEY, + title TEXT NOT NULL + ); + CREATE TABLE notes ( + body TEXT NOT NULL + ); + CREATE TABLE composite ( + team_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (team_id, user_id) + ); + CREATE TABLE wide_rows ( + id INTEGER PRIMARY KEY, + payload TEXT NOT NULL + ); + `); + + db.prepare("INSERT INTO users (name, email, status, created) VALUES (?, ?, ?, ?)").run( + "Alice", + "alice@example.com", + "active", + 1, + ); + db.prepare("INSERT INTO users (name, email, status, created) VALUES (?, ?, ?, ?)").run( + "Bob", + "bob@example.com", + "inactive", + 2, + ); + db.prepare("INSERT INTO users (name, email, status, created) VALUES (?, ?, ?, ?)").run( + "Carol", + "carol@example.com", + "active", + 3, + ); + db.prepare("INSERT INTO users (name, email, status, created) VALUES (?, ?, ?, ?)").run( + "Dave", + "dave@example.com", + "inactive", + 4, + ); + db.prepare("INSERT INTO users (name, email, status, created) VALUES (?, ?, ?, ?)").run( + "Eve", + "eve@example.com", + "active", + 5, + ); + db.prepare("INSERT INTO users (name, email, status, created) VALUES (?, ?, ?, ?)").run( + "Frank", + "frank@example.com", + "active", + 6, + ); + + db.prepare("INSERT INTO slugs (slug, title) VALUES (?, ?)").run("welcome", "Welcome"); + db.prepare("INSERT INTO slugs (slug, title) VALUES (?, ?)").run("about", "About"); + + db.prepare("INSERT INTO notes (body) VALUES (?)").run("First note"); + db.prepare("INSERT INTO notes (body) VALUES (?)").run("Second note"); + + db.prepare("INSERT INTO composite (team_id, user_id, value) VALUES (?, ?, ?)").run(1, 2, "pair"); + db.prepare("INSERT INTO wide_rows (id, payload) VALUES (?, ?)").run(1, "x".repeat(320)); + } finally { + db.close(); + } +} + +function readUserEmail(dbPath: string, id: number): string | null { + const db = new Database(dbPath, { readonly: true }); + try { + const row = db.prepare<{ email: string }, [number]>("SELECT email FROM users WHERE id = ?").get(id); + return row?.email ?? null; + } finally { + db.close(); + } +} + +function readUserCount(dbPath: string): number { + const db = new Database(dbPath, { readonly: true }); + try { + return db.prepare<{ count: number }, []>("SELECT COUNT(*) AS count FROM users").get()?.count ?? 0; + } finally { + db.close(); + } +} + +function readUserByEmail(dbPath: string, email: string): { name: string; email: string } | null { + const db = new Database(dbPath, { readonly: true }); + try { + return db + .prepare<{ name: string; email: string }, [string]>("SELECT name, email FROM users WHERE email = ?") + .get(email); + } finally { + db.close(); + } +} + +describe("SQLite tool support", () => { + let tmpDir: string; + let sqlitePath: string; + let sqliteDbPath: string; + let invalidDbPath: string; + let readTool: ReadTool; + let writeTool: WriteTool; + let originalEditVariant: string | undefined; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "sqlite-tool-test-")); + sqlitePath = path.join(tmpDir, "app.sqlite"); + sqliteDbPath = path.join(tmpDir, "app.db"); + invalidDbPath = path.join(tmpDir, "thumbs.db"); + originalEditVariant = Bun.env.PI_EDIT_VARIANT; + Bun.env.PI_EDIT_VARIANT = "replace"; + + createFixtureDatabase(sqlitePath); + await fs.copyFile(sqlitePath, sqliteDbPath); + await Bun.write(invalidDbPath, "not sqlite\nstill text\n"); + + const session = createSession(tmpDir); + readTool = new ReadTool(session); + writeTool = new WriteTool(session); + }); + + afterEach(async () => { + if (originalEditVariant === undefined) { + delete Bun.env.PI_EDIT_VARIANT; + } else { + Bun.env.PI_EDIT_VARIANT = originalEditVariant; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("parses SQLite path candidates at the extension boundary", () => { + expect(parseSqlitePathCandidates("data/app.db:users?limit=5")).toEqual([ + { + sqlitePath: "data/app.db", + subPath: "users", + queryString: "limit=5", + }, + ]); + expect(parseSqlitePathCandidates("data/app.sqlite")).toEqual([ + { + sqlitePath: "data/app.sqlite", + subPath: "", + queryString: "", + }, + ]); + }); + + it("parses SQLite selectors for row, query, and raw modes", () => { + expect(parseSqliteSelector("users:42", "")).toEqual({ kind: "row", table: "users", key: "42" }); + expect(parseSqliteSelector("users", "limit=2&offset=3&order=created:desc")).toEqual({ + kind: "query", + table: "users", + limit: 2, + offset: 3, + order: "created:desc", + where: undefined, + }); + expect(parseSqliteSelector("", "q=SELECT+1")).toEqual({ kind: "raw", sql: "SELECT 1" }); + }); + + it("lists tables for a .sqlite database and excludes sqlite internal tables", async () => { + const result = await readTool.execute("sqlite-list", { path: sqlitePath }); + const text = getText(result); + + expect(text).toContain("users (6 rows)"); + expect(text).toContain("slugs (2 rows)"); + expect(text).toContain("notes (2 rows)"); + expect(text).not.toContain("sqlite_sequence"); + }); + + it("lists tables for a .db database when the magic bytes match SQLite", async () => { + const result = await readTool.execute("sqlite-db-list", { path: sqliteDbPath }); + expect(getText(result)).toContain("users (6 rows)"); + }); + + it("falls through to plain file reading for non-SQLite .db files", async () => { + const result = await readTool.execute("sqlite-invalid-db", { path: invalidDbPath }); + expect(getText(result)).toContain("not sqlite"); + }); + + it("shows table schema and sample rows", async () => { + const result = await readTool.execute("sqlite-schema", { path: `${sqlitePath}:users` }); + const text = getText(result); + + expect(text).toContain("CREATE TABLE users"); + expect(text).toContain("Sample rows:"); + expect(text).toContain("Alice"); + }); + + it("returns a row by integer primary key", async () => { + const result = await readTool.execute("sqlite-row-int", { path: `${sqlitePath}:users:2` }); + const text = getText(result); + + expect(text).toContain("id: 2"); + expect(text).toContain("name: Bob"); + expect(text).toContain("email: bob@example.com"); + }); + + it("returns a row by text primary key", async () => { + const result = await readTool.execute("sqlite-row-text", { path: `${sqlitePath}:slugs:welcome` }); + const text = getText(result); + + expect(text).toContain("slug: welcome"); + expect(text).toContain("title: Welcome"); + }); + + it("falls back to ROWID lookups for tables without a declared primary key", async () => { + const result = await readTool.execute("sqlite-row-rowid", { path: `${sqlitePath}:notes:1` }); + expect(getText(result)).toContain("body: First note"); + }); + + it("errors on composite primary key row lookups", async () => { + await expect(readTool.execute("sqlite-row-composite", { path: `${sqlitePath}:composite:1` })).rejects.toThrow( + /composite primary key/i, + ); + }); + + it("supports pagination and includes a continuation hint", async () => { + const result = await readTool.execute("sqlite-page", { path: `${sqlitePath}:users?limit=2&offset=1` }); + const text = getText(result); + + expect(text).toContain("Bob"); + expect(text).toContain("Carol"); + expect(text).not.toContain("Alice"); + expect(text).toContain('use sel="users?limit=2&offset=3" to continue'); + }); + + it("supports where and order via the sel parameter", async () => { + const result = await readTool.execute("sqlite-sel-query", { + path: sqlitePath, + sel: "users?where=status='active'&order=created:desc&limit=2", + }); + const text = getText(result); + + expect(text).toContain("Frank"); + expect(text).toContain("Eve"); + expect(text).not.toContain("Bob"); + }); + + it("executes raw read-only SQL queries", async () => { + const result = await readTool.execute("sqlite-raw-query", { + path: `${sqlitePath}?q=SELECT+name+FROM+users+ORDER+BY+id+LIMIT+2`, + }); + const text = getText(result); + + expect(text).toContain("Alice"); + expect(text).toContain("Bob"); + }); + + it("rejects mutating raw queries on the readonly connection", async () => { + await expect( + readTool.execute("sqlite-raw-write", { + path: `${sqlitePath}?q=INSERT+INTO+users+(name,email,status,created)+VALUES+('X','x@example.com','active',7)`, + }), + ).rejects.toThrow(/readonly/i); + }); + + it("rejects table names that do not exist instead of interpolating them", async () => { + await expect( + readTool.execute("sqlite-injection-table", { path: `${sqlitePath}:users;DROP TABLE users;` }), + ).rejects.toThrow(/not found/i); + }); + + it("truncates wide rows to the configured table width", () => { + const rendered = renderTable(["id", "payload"], [{ id: 1, payload: "x".repeat(320) }], { + totalCount: 1, + offset: 0, + limit: 20, + table: "wide_rows", + dbPath: sqlitePath, + }); + + for (const line of rendered.split("\n")) { + expect(Bun.stringWidth(line)).toBeLessThanOrEqual(120); + } + }); + + it("inserts rows through the write tool with JSON5 content", async () => { + await writeTool.execute("sqlite-write-insert", { + path: `${sqlitePath}:users`, + content: "{ name: 'Grace', email: 'grace@example.com', status: 'active', created: 7 }", + }); + + expect(readUserByEmail(sqlitePath, "grace@example.com")).toEqual({ + name: "Grace", + email: "grace@example.com", + }); + }); + + it("updates rows through the write tool by primary key", async () => { + await writeTool.execute("sqlite-write-update", { + path: `${sqlitePath}:users:2`, + content: "{ email: 'bob+new@example.com' }", + }); + + expect(readUserEmail(sqlitePath, 2)).toBe("bob+new@example.com"); + }); + + it("deletes rows through the write tool with empty content", async () => { + await writeTool.execute("sqlite-write-delete", { + path: `${sqlitePath}:users:2`, + content: " ", + }); + + expect(readUserCount(sqlitePath)).toBe(5); + expect(readUserEmail(sqlitePath, 2)).toBeNull(); + }); + + it("enforces plan mode for SQLite writes", async () => { + const planSession = createSession(tmpDir, { + getPlanModeState: () => ({ + enabled: true, + planFilePath: path.join(tmpDir, "plan.md"), + }), + }); + const planWriteTool = new WriteTool(planSession); + + await expect( + planWriteTool.execute("sqlite-plan-mode", { + path: `${sqlitePath}:users:1`, + content: "{ email: 'blocked@example.com' }", + }), + ).rejects.toThrow(/Plan mode/i); + }); + + it("rejects writes to non-existent tables", async () => { + await expect( + writeTool.execute("sqlite-write-missing-table", { + path: `${sqlitePath}:missing`, + content: "{ value: 1 }", + }), + ).rejects.toThrow(/not found/i); + }); + + it("rejects writes to non-existent databases", async () => { + await expect( + writeTool.execute("sqlite-write-missing-db", { + path: path.join(tmpDir, "missing.sqlite:users"), + content: "{ name: 'Nope' }", + }), + ).rejects.toThrow(/not found/i); + }); + + it("rejects unknown columns in write content", async () => { + await expect( + writeTool.execute("sqlite-write-bad-column", { + path: `${sqlitePath}:users`, + content: "{ bogus: 1 }", + }), + ).rejects.toThrow(/no column named 'bogus'/i); + }); +});