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.
This commit is contained in:
Dery Ferdika authored and GitHub committed 2026-10-23 23:42:20 +09:00
1 parent e8dad898da
commit 89fea8130a
7 files changed
+177 -14

No files matched your search

+6 -1
View File
@@ -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
+32 -1
View File
@@ -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 {
+11
View File
@@ -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 &&
+21 -12
View File
@@ -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<void> => {
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)
@@ -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 }, [
{
@@ -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"),
}),
);
});
@@ -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));