perf(sqlite-reader): replaced full COUNT(*) scan with bounded row probing

- Added ROW_COUNT_PROBE_CAP to limit rows scanned when counting tables, preventing JS thread freezes on large databases.
- Used sqlite_stat1 estimates for tables exceeding the cap; exact counts only for provably small tables.
- Introduced TableRowCount type with exact/estimate/atLeast variants reflected in rendered output.
This commit is contained in:
can1357
2026-06-02 05:24:21 +02:00
parent 783971f575
commit b522fde56d
3 changed files with 178 additions and 10 deletions
@@ -6,7 +6,13 @@ 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 {
listTables,
parseSqlitePathCandidates,
parseSqliteSelector,
renderTable,
renderTableList,
} from "../../src/tools/sqlite-reader";
import { WriteTool } from "../../src/tools/write";
type ToolTextResult = {
@@ -418,3 +424,81 @@ describe("SQLite tool support", () => {
).rejects.toThrow(/no column named 'bogus'/i);
});
});
describe("SQLite table listing row counts", () => {
let tmpDir: string;
let dbPath: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "sqlite-count-test-"));
dbPath = path.join(tmpDir, "counts.db");
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
function seed(rowsPerTable: { big: number; small: number }): void {
const db = new Database(dbPath);
try {
db.run("CREATE TABLE big (id INTEGER PRIMARY KEY, v TEXT NOT NULL)");
db.run("CREATE TABLE small (id INTEGER PRIMARY KEY)");
const bigStmt = db.prepare("INSERT INTO big (v) VALUES (?)");
for (let i = 0; i < rowsPerTable.big; i++) bigStmt.run("x");
const smallStmt = db.prepare("INSERT INTO small DEFAULT VALUES");
for (let i = 0; i < rowsPerTable.small; i++) smallStmt.run();
} finally {
db.close();
}
}
function analyze(): void {
const db = new Database(dbPath);
try {
db.run("ANALYZE");
} finally {
db.close();
}
}
it("counts small tables exactly", () => {
seed({ big: 10, small: 2 });
const db = new Database(dbPath, { readonly: true });
try {
const rendered = renderTableList(listTables(db, { probeCap: 100 }));
expect(rendered).toContain("big (10 rows)");
expect(rendered).toContain("small (2 rows)");
} finally {
db.close();
}
});
it("reports the planner estimate for tables larger than the probe cap", () => {
seed({ big: 10, small: 2 });
analyze();
const db = new Database(dbPath, { readonly: true });
try {
// probeCap=5: big (estimate 10) exceeds it and is reported as an estimate
// without scanning; small (estimate 2) is counted exactly.
const rendered = renderTableList(listTables(db, { probeCap: 5 }));
expect(rendered).toContain("big (~10 rows)");
expect(rendered).toContain("small (2 rows)");
} finally {
db.close();
}
});
it("reports a lower bound when an unanalyzed table exceeds the probe cap", () => {
seed({ big: 10, small: 2 });
const db = new Database(dbPath, { readonly: true });
try {
// No ANALYZE, so no estimate exists; the bounded probe stops at the cap
// and reports a lower bound instead of scanning the whole table.
const rendered = renderTableList(listTables(db, { probeCap: 3 }));
expect(rendered).toContain("big (3+ rows)");
expect(rendered).toContain("small (2 rows)");
} finally {
db.close();
}
});
});