Skip to content

Commit e4b9ba9

Browse files
sjfclaude
andcommitted
fix(sqlite): support Node 23.0–23.10 runtimes lacking StatementSync.columns()
node:sqlite added StatementSync.columns() in v22.16/v23.11, but it is absent on Node 23.0–23.10 — runtimes that still satisfy engines (>=22.19.0). The Kysely sync and driver execute paths called it unconditionally, so a post-upgrade `openclaw doctor` crashed the plugin install index and task registry/flow sidecar migrations with "statement.columns is not a function". Centralize a single executeCompiledQuerySync that feature-detects columns() and, when absent, classifies result-returning statements from the compiled query shape. Collapses the previously duplicated sync/driver execute logic into one path. Fixes #90007 Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent b14923d commit e4b9ba9

3 files changed

Lines changed: 133 additions & 53 deletions

File tree

src/infra/kysely-node-sqlite.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { DatabaseSync } from "node:sqlite";
33
import { CompiledQuery, Kysely, sql, type Generated } from "kysely";
44
import { afterEach, describe, expect, it, vi } from "vitest";
55
import { NodeSqliteKyselyDialect } from "./kysely-node-sqlite.js";
6+
import { executeSqliteQuerySync, getNodeSqliteKysely } from "./kysely-sync.js";
67

78
type TestDatabase = {
89
person: {
@@ -100,6 +101,61 @@ describe("NodeSqliteKyselyDialect", () => {
100101
expect(ignoredInsert.numAffectedRows).toBe(0n);
101102
});
102103

104+
it("classifies builder statements from the Kysely node when StatementSync.columns is unavailable", async () => {
105+
// Node 23.0–23.10 ship node:sqlite without StatementSync.columns() (added in
106+
// v22.16/v23.11) yet still satisfy the >=22.19 engines floor. With columns()
107+
// hidden, the driver must pick all()/run() from the compiled query node.
108+
db = new Kysely<TestDatabase>({
109+
dialect: new NodeSqliteKyselyDialect({
110+
database: withoutStatementColumns(new DatabaseSync(":memory:")),
111+
}),
112+
});
113+
await createPersonTable(db);
114+
115+
const insertResult = await db
116+
.insertInto("person")
117+
.values({ name: "Ada" })
118+
.executeTakeFirstOrThrow();
119+
expect(insertResult.insertId).toBe(1n);
120+
expect(insertResult.numInsertedOrUpdatedRows).toBe(1n);
121+
122+
await expect(db.selectFrom("person").selectAll().execute()).resolves.toEqual([
123+
{ id: 1, name: "Ada" },
124+
]);
125+
await expect(
126+
db.insertInto("person").values({ name: "Grace" }).returning(["id", "name"]).execute(),
127+
).resolves.toEqual([{ id: 2, name: "Grace" }]);
128+
129+
const updateResult = await db
130+
.updateTable("person")
131+
.set({ name: "Ada Lovelace" })
132+
.where("id", "=", 1)
133+
.executeTakeFirstOrThrow();
134+
expect(updateResult.numUpdatedRows).toBe(1n);
135+
136+
const deleteResult = await db
137+
.deleteFrom("person")
138+
.where("id", "=", 2)
139+
.executeTakeFirstOrThrow();
140+
expect(deleteResult.numDeletedRows).toBe(1n);
141+
});
142+
143+
it("runs the sync helper path when StatementSync.columns is unavailable", () => {
144+
// This is the exact path that crashed in the report: state migrations call
145+
// executeSqliteQuerySync, which prepared a statement and called columns() —
146+
// absent on Node 23.0–23.10. Drive the sync helper with columns() hidden.
147+
const raw = withoutStatementColumns(new DatabaseSync(":memory:"));
148+
raw.exec("create table person (id integer primary key autoincrement, name text not null)");
149+
const kdb = getNodeSqliteKysely<TestDatabase>(raw);
150+
151+
const inserted = executeSqliteQuerySync(raw, kdb.insertInto("person").values({ name: "Ada" }));
152+
expect(inserted.insertId).toBe(1n);
153+
expect(inserted.numAffectedRows).toBe(1n);
154+
155+
const selected = executeSqliteQuerySync(raw, kdb.selectFrom("person").selectAll());
156+
expect(selected.rows).toEqual([{ id: 1, name: "Ada" }]);
157+
});
158+
103159
it("rolls back transactions and controlled savepoints", async () => {
104160
db = new Kysely<TestDatabase>({
105161
dialect: new NodeSqliteKyselyDialect({
@@ -161,6 +217,32 @@ async function createTestDb(): Promise<Kysely<TestDatabase>> {
161217
return testDb;
162218
}
163219

220+
// Mimics a node:sqlite build without StatementSync.columns() by hiding that
221+
// method on every prepared statement while leaving the rest of the native API
222+
// (bound to the real handle) intact.
223+
function withoutStatementColumns(db: DatabaseSync): DatabaseSync {
224+
return new Proxy(db, {
225+
get(target, prop, receiver) {
226+
if (prop === "prepare") {
227+
return (statementSql: string) => {
228+
const statement = target.prepare(statementSql);
229+
return new Proxy(statement, {
230+
get(stmt, key) {
231+
if (key === "columns") {
232+
return undefined;
233+
}
234+
const value = stmt[key as keyof typeof stmt];
235+
return typeof value === "function" ? value.bind(stmt) : value;
236+
},
237+
});
238+
};
239+
}
240+
const value = Reflect.get(target, prop, receiver);
241+
return typeof value === "function" ? value.bind(target) : value;
242+
},
243+
});
244+
}
245+
164246
async function createPersonTable(testDb: Kysely<TestDatabase>): Promise<void> {
165247
await testDb.schema
166248
.createTable("person")

src/infra/kysely-node-sqlite.ts

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Adapts Node's sync sqlite API to Kysely.
2-
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
2+
import type { DatabaseSync, SQLInputValue, StatementSync } from "node:sqlite";
33
import type {
44
DatabaseConnection,
55
DatabaseIntrospector,
@@ -13,11 +13,15 @@ import type {
1313
} from "kysely";
1414
import {
1515
CompiledQuery,
16+
DeleteQueryNode,
1617
IdentifierNode,
18+
InsertQueryNode,
1719
RawNode,
20+
SelectQueryNode,
1821
SqliteAdapter,
1922
SqliteIntrospector,
2023
SqliteQueryCompiler,
24+
UpdateQueryNode,
2125
createQueryId,
2226
} from "kysely";
2327

@@ -150,26 +154,7 @@ class NodeSqliteKyselyConnection implements DatabaseConnection {
150154
}
151155

152156
executeQuery<O>(compiledQuery: CompiledQuery): Promise<QueryResult<O>> {
153-
const { sql, parameters } = compiledQuery;
154-
const stmt = this.#db.prepare(sql);
155-
const sqliteParameters = parameters as SQLInputValue[];
156-
157-
if (stmt.columns().length > 0) {
158-
return Promise.resolve({ rows: stmt.all(...sqliteParameters) as O[] });
159-
}
160-
161-
const { changes, lastInsertRowid } = stmt.run(...sqliteParameters);
162-
const baseResult: QueryResult<O> = {
163-
numAffectedRows: BigInt(changes),
164-
rows: [],
165-
};
166-
if (isInsertStatement(sql) && changes > 0) {
167-
return Promise.resolve({
168-
...baseResult,
169-
insertId: BigInt(lastInsertRowid),
170-
});
171-
}
172-
return Promise.resolve(baseResult);
157+
return Promise.resolve(executeCompiledQuerySync<O>(this.#db, compiledQuery));
173158
}
174159

175160
async *streamQuery<O>(
@@ -185,8 +170,47 @@ class NodeSqliteKyselyConnection implements DatabaseConnection {
185170
}
186171
}
187172

188-
function isInsertStatement(sql: string): boolean {
189-
return sql.trimStart().toLowerCase().startsWith("insert");
173+
/** Execute a compiled Kysely query synchronously against node:sqlite. */
174+
export function executeCompiledQuerySync<O>(
175+
db: DatabaseSync,
176+
compiledQuery: CompiledQuery,
177+
): QueryResult<O> {
178+
const statement = db.prepare(compiledQuery.sql);
179+
const parameters = compiledQuery.parameters as SQLInputValue[];
180+
181+
if (statementReturnsRows(statement, compiledQuery)) {
182+
return { rows: statement.all(...parameters) as O[] };
183+
}
184+
185+
const { changes, lastInsertRowid } = statement.run(...parameters);
186+
const result: QueryResult<O> = {
187+
numAffectedRows: BigInt(changes),
188+
rows: [],
189+
};
190+
if (InsertQueryNode.is(compiledQuery.query) && changes > 0) {
191+
return { ...result, insertId: BigInt(lastInsertRowid) };
192+
}
193+
return result;
194+
}
195+
196+
// node:sqlite added StatementSync.columns() in v22.16/v23.11; it asks SQLite
197+
// directly whether a prepared statement yields a result set. Node 23.0–23.10
198+
// (still >=22.19, so allowed by engines) lack it, so fall back to the compiled
199+
// Kysely node. That is exact here: callers only execute builder queries through
200+
// this dialect, and the dialect itself only raw-executes transaction-control
201+
// statements (begin/commit/rollback/savepoint), none of which return rows.
202+
function statementReturnsRows(statement: StatementSync, compiledQuery: CompiledQuery): boolean {
203+
if (typeof statement.columns === "function") {
204+
return statement.columns().length > 0;
205+
}
206+
const node = compiledQuery.query;
207+
if (SelectQueryNode.is(node)) {
208+
return true;
209+
}
210+
if (InsertQueryNode.is(node) || UpdateQueryNode.is(node) || DeleteQueryNode.is(node)) {
211+
return node.returning != null;
212+
}
213+
return false;
190214
}
191215

192216
function createSavepointCommand(command: string, savepointName: string): RawNode {

src/infra/kysely-sync.ts

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// Adapts node:sqlite sync database calls for Kysely-style query execution.
2-
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
2+
import type { DatabaseSync } from "node:sqlite";
33
import type { CompiledQuery, Kysely, QueryResult } from "kysely";
4-
import { InsertQueryNode, Kysely as KyselyInstance } from "kysely";
5-
import { NodeSqliteKyselyDialect } from "./kysely-node-sqlite.js";
4+
import { Kysely as KyselyInstance } from "kysely";
5+
import { NodeSqliteKyselyDialect, executeCompiledQuerySync } from "./kysely-node-sqlite.js";
66

77
// Sync query helpers execute compiled Kysely SQL against node:sqlite without
88
// going through Kysely's async driver path.
@@ -24,38 +24,12 @@ export function getNodeSqliteKysely<Database>(db: DatabaseSync): Kysely<Database
2424
return kysely;
2525
}
2626

27-
/** Execute a compiled Kysely query synchronously against node:sqlite. */
28-
export function executeCompiledSqliteQuerySync<Row>(
29-
db: DatabaseSync,
30-
compiledQuery: CompiledQuery<Row>,
31-
): QueryResult<Row> {
32-
const statement = db.prepare(compiledQuery.sql);
33-
const parameters = compiledQuery.parameters as SQLInputValue[];
34-
35-
if (statement.columns().length > 0) {
36-
return { rows: statement.all(...parameters) as Row[] };
37-
}
38-
39-
const { changes, lastInsertRowid } = statement.run(...parameters);
40-
const result: QueryResult<Row> = {
41-
numAffectedRows: BigInt(changes),
42-
rows: [],
43-
};
44-
if (InsertQueryNode.is(compiledQuery.query) && changes > 0) {
45-
return {
46-
...result,
47-
insertId: BigInt(lastInsertRowid),
48-
};
49-
}
50-
return result;
51-
}
52-
5327
/** Compile and execute a Kysely query synchronously. */
5428
export function executeSqliteQuerySync<Row>(
5529
db: DatabaseSync,
5630
query: CompilableQuery<Row>,
5731
): QueryResult<Row> {
58-
return executeCompiledSqliteQuerySync<Row>(db, query.compile());
32+
return executeCompiledQuerySync<Row>(db, query.compile());
5933
}
6034

6135
/** Execute a Kysely query synchronously and return its first row. */

0 commit comments

Comments
 (0)