fix(sqlite): budget schema names by returned JSON text

Charge the whole-catalog admission guard by escaped decoded names, preserving raw-byte lookup identity and final worker reply limits. Keep UTF-16 inventory and detailed-schema results whose returned facts fit the documented allowance.

Fixes #6888
This commit is contained in:
morluto committed 2026-10-24 02:34:35 +08:00
1 parent 3787ec5f83
commit 25aaf89287
5 files changed
+121 -2

No files matched your search

+3
View File
@@ -128,6 +128,9 @@ bounds simultaneous input buffering and private snapshot/worker counts.
The database and WAL together are limited to 256 MiB. SQLite values/records,
SQL statements and the complete worker JSON reply each have a 16 MiB limit.
Schema names count by their returned JSON text, including escaping, for UTF-8,
UTF-16le and UTF-16be databases. Internal raw-byte lookup keys do not consume
the reply allowance; exact name identity still comes from those stored bytes.
The owned worker has a 256 MiB JavaScript heap limit and defaults to a 30,000 ms
deadline. Select optional MCP `worker_timeout_ms`, or CLI
`--worker-timeout-ms <milliseconds>`, to change the budget for that call. Values
+6 -2
View File
@@ -324,7 +324,7 @@ const readSchema = (
// Names bound eventual output only when the whole catalog is requested.
// Selected scope retains one table and at most one possible shadow parent;
// unrelated names are examined without retaining them or charging the reply.
const metadataBudget = new OutputBudget();
const catalogNameBudget = new OutputBudget();
for (const raw of db
.prepare(
"SELECT schema, name AS display_name, CAST(name AS BLOB) AS name, type, ncol, wr, strict FROM temp.pragma_table_list WHERE schema = 'main'",
@@ -333,7 +333,11 @@ const readSchema = (
const value = tableRow.parse(raw);
const nameKey = sqliteNameBytesKey(value.name);
if (selectedName === undefined) {
metadataBudget.retain(nameKey);
// The private hex key grows with the database encoding, but is never
// returned. Admit catalog names by their decoded JSON representation;
// display text is used only for this lower bound, never for identity.
// Stored schema bytes still undergo strict decoding below.
catalogNameBudget.retain(value.display_name);
metadata.set(nameKey, value);
} else if (nameKey === selectedKey) {
metadata.set(nameKey, value);
@@ -21,8 +21,11 @@ import {
import {
createSqliteApplicationCollationFixture,
createSqliteLongNameFixture,
SQLITE_NATIVE_LIMITS_AVAILABLE,
} from "../fixtures/sqlite/database.js";
import { parseEvidence } from "../../src/domain/evidence.js";
import { sqliteDatabaseSchema } from "../../src/domain/sqlite/sqliteDatabase.js";
import { connectLocalToolsMcp } from "../fixtures/localToolsMcp.js";
import { parseMcpToolError } from "../fixtures/mcpToolError.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
@@ -1137,6 +1140,47 @@ cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)(
},
);
cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)(
"returns complete UTF-16 schemas through the worker and public CLI when JSON fits",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-sqlite-large-catalog-");
for (const [encoding, scope] of [
["UTF-16le", "inventory"],
["UTF-16be", "database"],
] as const) {
const { path, names } = createSqliteLongNameFixture(root, encoding);
const response = await runFileCli(cli, {
arguments: [
"inspect-sqlite-database",
path,
"--schema-scope",
scope,
"--json",
],
environment: { REA_LOG_LEVEL: "silent" },
});
expect(response.exitCode, response.stderr).toBe(0);
const evidence = parseEvidence(response.json);
const report = sqliteDatabaseSchema.parse(evidence.normalized_result);
expect(evidence.parameters.schema_scope).toBe(scope);
expect(report.database_encoding).toBe(encoding);
expect(report.schema).toMatchObject({ scope, completeness: "complete" });
expect(report.schema.tables).toHaveLength(names.length);
expect(report.schema.tables.map((table) => table.name)).toEqual(
expect.arrayContaining(names),
);
expect(
report.schema.tables.every(
(table) =>
table.columns_completeness ===
(scope === "inventory" ? "not_requested" : "complete"),
),
).toBe(true);
expect(report.rows).toBeNull();
}
},
);
cliTest.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE)(
"keeps selected table metadata tied to exact schema-name bytes through CLI and MCP",
async ({ cli }) => {
@@ -3,10 +3,12 @@ import { readFile, writeFile } from "node:fs/promises";
import { expect, it as nativeTest, onTestFinished } from "vitest";
import {
createSqliteApplicationCollationFixture,
createSqliteLongNameFixture,
SQLITE_NATIVE_LIMITS_AVAILABLE,
} from "../../fixtures/sqlite/database.js";
import { inspectSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseInspection.js";
import { SqliteInspectionFailure } from "../../../src/sqlite/SqliteDatabaseLimits.js";
import { jsonByteLength } from "../../../src/domain/jsonSerialization.js";
import {
createTestWorkspace,
removeTestWorkspace,
@@ -238,6 +240,50 @@ it("counts schema names once when enforcing the complete reply budget", async ()
);
});
it.each(["UTF-16le", "UTF-16be"] as const)(
"budgets %s catalog names by returned JSON rather than internal hex identity",
async (encoding) => {
const workspace = await createTestWorkspace("rea-sqlite-catalog-names-");
onTestFinished(() => removeTestWorkspace(workspace.root));
const { path, names } = createSqliteLongNameFixture(
workspace.root,
encoding,
);
for (const schema_scope of ["inventory", "database"] as const) {
const inspection = inspectSqliteDatabaseSnapshot(path, {
path,
schema_scope,
});
expect(inspection.database_encoding).toBe(encoding);
expect(inspection.schema).toMatchObject({
scope: schema_scope,
completeness: "complete",
});
expect(inspection.schema.tables).toHaveLength(names.length);
expect(inspection.schema.tables.map((table) => table.name)).toEqual(
expect.arrayContaining(names),
);
for (const table of inspection.schema.tables) {
expect(table.columns_completeness).toBe(
schema_scope === "inventory" ? "not_requested" : "complete",
);
if (schema_scope === "database") {
expect(table.sql).toBe(`CREATE TABLE "${table.name}"(value TEXT)`);
expect(table.columns).toMatchObject([
{ name: "value", declared_type: "TEXT" },
]);
} else {
expect(table.sql).toBeUndefined();
expect(table.columns).toEqual([]);
}
}
expect(jsonByteLength({ ok: true, inspection })).toBeLessThan(
16 * 1024 * 1024,
);
}
},
);
it("keeps unrelated catalog names outside the selected-table reply budget", async () => {
const path = await createDatabase(
"CREATE TABLE selected(value TEXT); INSERT INTO selected VALUES ('retained');",
+22
View File
@@ -100,3 +100,25 @@ export const createSqliteApplicationCollationFixture = (
}
return path;
};
/** UTF-16 hex lookup keys exceed 16 MiB while both public schema scopes fit. */
export const createSqliteLongNameFixture = (
root: string,
encoding: "UTF-8" | "UTF-16le" | "UTF-16be",
): { path: string; names: string[] } => {
const path = join(root, `large-schema-${encoding}.db`);
const names = Array.from(
{ length: 34 },
(_, index) => `${index}_${"x".repeat(128 * 1024)}`,
);
const producer = new DatabaseSync(path);
try {
producer.exec(`PRAGMA encoding='${encoding}'; BEGIN`);
for (const name of names)
producer.exec(`CREATE TABLE "${name}"(value TEXT)`);
producer.exec("COMMIT");
} finally {
producer.close();
}
return { path, names };
};