From 89fea8130ab0e6243d1fe0d0d297c7e38ac9f258 Mon Sep 17 00:00:00 2001 From: Dery Ferdika <114888155+DeryFerd@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:42:20 +0700 Subject: [PATCH] fix(sqlite): report result-column capacity refusals as capacity (#6327) A projection wider than SQLite's native result-column limit failed with the generic "too many columns in result set" SQL error. The inspector could not attribute that error to a generated column, so the worker reported "format" and the parent blamed the caller's path as invalid_format even though the selected database was readable. Classify the refusal from SQLite's own column limit for the private connection, carry the selected and engine counts through the worker reply, and project it as a capacity resource constraint. The database stays readable, no partial rows are returned, and the recovery names the column counts and splitting the projection across calls. --- docs/sqlite-databases.md | 7 ++- src/sqlite/SqliteDatabaseInspection.ts | 33 ++++++++++++- src/sqlite/SqliteDatabaseLimits.ts | 11 +++++ src/sqlite/SqliteDatabaseWorker.ts | 33 ++++++++----- src/sqlite/SqliteDatabaseWorkerExecution.ts | 22 +++++++++ .../sqlite/sqliteDatabaseInspection.test.ts | 39 ++++++++++++++++ .../sqlite/sqliteDatabaseProvider.test.ts | 46 +++++++++++++++++++ 7 files changed, 177 insertions(+), 14 deletions(-) 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));