diff --git a/docs/sqlite-databases.md b/docs/sqlite-databases.md index 2d9ad6c76c..a6b67b0aa9 100644 --- a/docs/sqlite-databases.md +++ b/docs/sqlite-databases.md @@ -66,7 +66,12 @@ whose sampled values were read; stored and other generated cells stay readable. cause a format refusal when all columns are requested. In inventory scope, an explicit valid subset can still be sampled without decoding unrelated malformed column names. Column selection requires a -table and at least one name; empty-string identifiers are valid. `row_limit` +table and at least one name; empty-string identifiers are valid. SQLite counts +every projected name, including a repeated name, against its native +result-column capacity; a wider projection is refused as `resource_constraint` +with `resource: "capacity"`, reporting the selected count and this connection's +column limit. The selected database is readable and is not reported as +malformed; request fewer columns per call instead. `row_limit` requires `table`, defaults to 20 and accepts positive safe integers. Samples remain subject to the byte and worker resource limits below, regardless of row count. The result reports the selected limit, returned count and whether more rows were observed. Row diff --git a/src/sqlite/SqliteDatabaseInspection.ts b/src/sqlite/SqliteDatabaseInspection.ts index 205db7b1f3..9c71dfd45f 100644 --- a/src/sqlite/SqliteDatabaseInspection.ts +++ b/src/sqlite/SqliteDatabaseInspection.ts @@ -11,6 +11,7 @@ import { import { SQLITE_DATABASE_LIMITS, SqliteColumnEvaluationFailure, + SqliteColumnLimitFailure, SqliteInspectionFailure, sqlitePrimaryResultCode, } from "./SqliteDatabaseLimits.js"; @@ -462,6 +463,34 @@ const storedColumnNames = ( return names; }; +/** + * SQLite counts every projected expression, including repeated selections, + * against this connection's result-column capacity and reports exhaustion as a + * generic SQL error. The engine's own limit distinguishes that capacity refusal + * from malformed or unreadable data. + */ +const resultColumnLimitFailure = ( + db: DatabaseSync, + cause: unknown, + selectedColumns: number, +): SqliteColumnLimitFailure | undefined => { + if (sqlitePrimaryResultCode(cause) !== SQLITE_ERROR) return undefined; + const engineColumnLimit = db.limits.column; + if ( + !Number.isSafeInteger(engineColumnLimit) || + engineColumnLimit <= 0 || + selectedColumns <= engineColumnLimit + ) + return undefined; + return new SqliteColumnLimitFailure( + `SQLite cannot return ${String(selectedColumns)} selected result columns; this connection permits at most ${String(engineColumnLimit)}: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + selectedColumns, + engineColumnLimit, + ); +}; + /** * Attribute an SQL error from selected cells to the virtual generated columns * that raise it on this connection. Each column is probed alone over the same @@ -479,8 +508,10 @@ const columnEvaluationFailure = ( readonly encoding: DatabaseEncoding; }, ): unknown => { - if (sqlitePrimaryResultCode(cause) !== SQLITE_ERROR) return cause; const { table, columns, source, limit, encoding } = selection; + const columnLimit = resultColumnLimitFailure(db, cause, columns.length); + if (columnLimit !== undefined) return columnLimit; + if (sqlitePrimaryResultCode(cause) !== SQLITE_ERROR) return cause; // The aggregate evaluates exactly the sampled cells, as the length pass does. const probe = (name: string): { readonly failure: unknown } | undefined => { try { diff --git a/src/sqlite/SqliteDatabaseLimits.ts b/src/sqlite/SqliteDatabaseLimits.ts index 3b6fbd6c20..05cc4c50b8 100644 --- a/src/sqlite/SqliteDatabaseLimits.ts +++ b/src/sqlite/SqliteDatabaseLimits.ts @@ -13,6 +13,7 @@ export class SqliteInspectionFailure extends Error { | "selection" | "column-selection" | "column-evaluation" + | "column-limit" | "unsupported" | "unavailable" | "output-limit" @@ -32,6 +33,16 @@ export class SqliteColumnEvaluationFailure extends SqliteInspectionFailure { super("column-evaluation", message); } } +/** A projection above this connection's result-column capacity; the database stays readable. */ +export class SqliteColumnLimitFailure extends SqliteInspectionFailure { + constructor( + message: string, + readonly selectedColumns: number, + readonly engineColumnLimit: number, + ) { + super("column-limit", message); + } +} /** SQLite's primary result code for a native node:sqlite failure, if it carries one. */ export const sqlitePrimaryResultCode = (cause: unknown): number | undefined => cause instanceof Error && diff --git a/src/sqlite/SqliteDatabaseWorker.ts b/src/sqlite/SqliteDatabaseWorker.ts index c55528a5d3..5464f189e6 100644 --- a/src/sqlite/SqliteDatabaseWorker.ts +++ b/src/sqlite/SqliteDatabaseWorker.ts @@ -5,6 +5,7 @@ import { inspectSqliteDatabaseSnapshot } from "./SqliteDatabaseInspection.js"; import { SQLITE_DATABASE_LIMITS, SqliteColumnEvaluationFailure, + SqliteColumnLimitFailure, SqliteInspectionFailure, sqlitePrimaryResultCode, } from "./SqliteDatabaseLimits.js"; @@ -41,18 +42,26 @@ const main = async (): Promise => { message, readable_columns: cause.readableColumns, } - : { - ok: false, - reason: - cause instanceof SqliteInspectionFailure - ? cause.reason - : code === 7 - ? "memory" - : code === 18 - ? "output-limit" - : "format", - message, - }; + : cause instanceof SqliteColumnLimitFailure + ? { + ok: false, + reason: cause.reason, + message, + selected_columns: cause.selectedColumns, + engine_column_limit: cause.engineColumnLimit, + } + : { + ok: false, + reason: + cause instanceof SqliteInspectionFailure + ? cause.reason + : code === 7 + ? "memory" + : code === 18 + ? "output-limit" + : "format", + message, + }; } let encoded = JSON.stringify(reply); if (Buffer.byteLength(encoded) > SQLITE_DATABASE_LIMITS.outputBytes) diff --git a/src/sqlite/SqliteDatabaseWorkerExecution.ts b/src/sqlite/SqliteDatabaseWorkerExecution.ts index 450d13a1c4..1917469f6f 100644 --- a/src/sqlite/SqliteDatabaseWorkerExecution.ts +++ b/src/sqlite/SqliteDatabaseWorkerExecution.ts @@ -59,6 +59,13 @@ const replySchema = z.union([ message: z.string(), readable_columns: z.array(z.string()), }), + z.strictObject({ + ok: z.literal(false), + reason: z.literal("column-limit"), + message: z.string(), + selected_columns: z.int().positive(), + engine_column_limit: z.int().positive(), + }), ]); /** Replaceable launcher retains the shared owned-process supervision boundary. */ export type SqliteWorkerLauncher = NonNullable< @@ -128,6 +135,21 @@ const rejectedReply = ( "The SQLite value/record and worker JSON reply allowances are fixed at 16 MiB; worker_timeout_ms and client deadlines do not raise them. If it preserves the requested facts, select exact columns, reduce row_limit, or use schema_scope: inventory instead of database. This worker refusal leaves no completed Evidence to retrieve. Do not repeat the unchanged request or truncate required database bytes.", }, ); + if (reply.reason === "column-limit") + return new AnalysisResourceConstraintError( + operation, + "capacity", + reply.message, + { + selected_result_columns: reply.selected_columns, + engine_result_column_limit: reply.engine_column_limit, + }, + { + capturedOutput, + retryable: false, + remediationAction: `The selected database is readable; only the combined projection exceeded this connection's native result-column limit. Request at most ${String(reply.engine_column_limit)} columns per call, splitting wider facts across calls, or omit table to inspect stored declarations with schema_scope: database. The limit belongs to SQLite's connection, so worker_timeout_ms and client deadlines do not raise it. Do not repeat the unchanged request.`, + }, + ); const argument = rejectedArgument[reply.reason]; return new AnalysisInputError(operation, { capturedOutput }, [ { diff --git a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts index f6a9adfc5f..a22df47761 100644 --- a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts @@ -611,3 +611,42 @@ nativeTest.skipIf(SQLITE_NATIVE_LIMITS_AVAILABLE)( ).toThrow("DatabaseSync.limits"); }, ); + +it("classifies SQLite's result-column capacity refusal without blaming the database", async () => { + const path = await createDatabase( + "CREATE TABLE records(email TEXT); INSERT INTO records VALUES ('sample');", + ); + const reader = new DatabaseSync(path, { readOnly: true }); + const engineColumnLimit = (() => { + try { + return reader.limits.column; + } finally { + reader.close(); + } + })(); + const columns = (count: number): string[] => + Array.from({ length: count }, () => "email"); + const atLimit = inspectSqliteDatabaseSnapshot(path, { + path, + table: "records", + columns: columns(engineColumnLimit), + }); + expect(atLimit.rows).toMatchObject({ + columns: columns(engineColumnLimit), + returned_rows: 1, + }); + expect(() => + inspectSqliteDatabaseSnapshot(path, { + path, + table: "records", + columns: columns(engineColumnLimit + 1), + }), + ).toThrow( + expect.objectContaining({ + reason: "column-limit", + selectedColumns: engineColumnLimit + 1, + engineColumnLimit, + message: expect.stringContaining("too many columns in result set"), + }), + ); +}); diff --git a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts index 62ae1a6fbc..619cd9fc0f 100644 --- a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts @@ -804,6 +804,52 @@ it("attributes table and column selection failures to their own arguments", asyn expect(selected.value.result).toMatchObject({ rows: { columns: ["id"] } }); }); +it("reports a projection above SQLite's result-column capacity as capacity, not invalid input", async () => { + const { database } = await createFixture(); + const reader = new DatabaseSync(database.path, { readOnly: true }); + const engineColumnLimit = (() => { + try { + return reader.limits.column; + } finally { + reader.close(); + } + })(); + const provider = createProvider(); + onTestFinished(() => provider.close()); + const columns = (count: number): string[] => + Array.from({ length: count }, () => "text_value"); + const atLimit = await provider.inspect({ + path: database.path, + table: "records", + columns: columns(engineColumnLimit), + row_limit: 1, + }); + if (!atLimit.ok) throw atLimit.error; + expect(atLimit.value.result).toMatchObject({ + rows: { columns: columns(engineColumnLimit), returned_rows: 1 }, + }); + + const overLimit = await provider.inspect({ + path: database.path, + table: "records", + columns: columns(engineColumnLimit + 1), + row_limit: 1, + }); + if (overLimit.ok) throw new Error("Expected a rejected projection"); + const projection = projectAnalysisError(overLimit.error); + expect(projection).toMatchObject({ + code: "resource_constraint", + details: { + resource: "capacity", + reported_limits: { + selected_result_columns: engineColumnLimit + 1, + engine_result_column_limit: engineColumnLimit, + }, + }, + }); + expect(JSON.stringify(projection)).not.toContain("invalid_format"); +}); + it("rejects malformed databases without altering the selected file", async () => { const workspace = await createTestWorkspace("rea-sqlite-invalid-"); onTestFinished(() => removeTestWorkspace(workspace.root));