Compare commits

...

1 Commits

Author SHA1 Message Date
Sarah Fortune
e4b9ba94c7 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 <noreply@anthropic.com>
2026-06-08 14:32:24 -07:00
3 changed files with 133 additions and 53 deletions

View File

@@ -3,6 +3,7 @@ import { DatabaseSync } from "node:sqlite";
import { CompiledQuery, Kysely, sql, type Generated } from "kysely";
import { afterEach, describe, expect, it, vi } from "vitest";
import { NodeSqliteKyselyDialect } from "./kysely-node-sqlite.js";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "./kysely-sync.js";
type TestDatabase = {
person: {
@@ -100,6 +101,61 @@ describe("NodeSqliteKyselyDialect", () => {
expect(ignoredInsert.numAffectedRows).toBe(0n);
});
it("classifies builder statements from the Kysely node when StatementSync.columns is unavailable", async () => {
// Node 23.023.10 ship node:sqlite without StatementSync.columns() (added in
// v22.16/v23.11) yet still satisfy the >=22.19 engines floor. With columns()
// hidden, the driver must pick all()/run() from the compiled query node.
db = new Kysely<TestDatabase>({
dialect: new NodeSqliteKyselyDialect({
database: withoutStatementColumns(new DatabaseSync(":memory:")),
}),
});
await createPersonTable(db);
const insertResult = await db
.insertInto("person")
.values({ name: "Ada" })
.executeTakeFirstOrThrow();
expect(insertResult.insertId).toBe(1n);
expect(insertResult.numInsertedOrUpdatedRows).toBe(1n);
await expect(db.selectFrom("person").selectAll().execute()).resolves.toEqual([
{ id: 1, name: "Ada" },
]);
await expect(
db.insertInto("person").values({ name: "Grace" }).returning(["id", "name"]).execute(),
).resolves.toEqual([{ id: 2, name: "Grace" }]);
const updateResult = await db
.updateTable("person")
.set({ name: "Ada Lovelace" })
.where("id", "=", 1)
.executeTakeFirstOrThrow();
expect(updateResult.numUpdatedRows).toBe(1n);
const deleteResult = await db
.deleteFrom("person")
.where("id", "=", 2)
.executeTakeFirstOrThrow();
expect(deleteResult.numDeletedRows).toBe(1n);
});
it("runs the sync helper path when StatementSync.columns is unavailable", () => {
// This is the exact path that crashed in the report: state migrations call
// executeSqliteQuerySync, which prepared a statement and called columns() —
// absent on Node 23.023.10. Drive the sync helper with columns() hidden.
const raw = withoutStatementColumns(new DatabaseSync(":memory:"));
raw.exec("create table person (id integer primary key autoincrement, name text not null)");
const kdb = getNodeSqliteKysely<TestDatabase>(raw);
const inserted = executeSqliteQuerySync(raw, kdb.insertInto("person").values({ name: "Ada" }));
expect(inserted.insertId).toBe(1n);
expect(inserted.numAffectedRows).toBe(1n);
const selected = executeSqliteQuerySync(raw, kdb.selectFrom("person").selectAll());
expect(selected.rows).toEqual([{ id: 1, name: "Ada" }]);
});
it("rolls back transactions and controlled savepoints", async () => {
db = new Kysely<TestDatabase>({
dialect: new NodeSqliteKyselyDialect({
@@ -161,6 +217,32 @@ async function createTestDb(): Promise<Kysely<TestDatabase>> {
return testDb;
}
// Mimics a node:sqlite build without StatementSync.columns() by hiding that
// method on every prepared statement while leaving the rest of the native API
// (bound to the real handle) intact.
function withoutStatementColumns(db: DatabaseSync): DatabaseSync {
return new Proxy(db, {
get(target, prop, receiver) {
if (prop === "prepare") {
return (statementSql: string) => {
const statement = target.prepare(statementSql);
return new Proxy(statement, {
get(stmt, key) {
if (key === "columns") {
return undefined;
}
const value = stmt[key as keyof typeof stmt];
return typeof value === "function" ? value.bind(stmt) : value;
},
});
};
}
const value = Reflect.get(target, prop, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
});
}
async function createPersonTable(testDb: Kysely<TestDatabase>): Promise<void> {
await testDb.schema
.createTable("person")

View File

@@ -1,5 +1,5 @@
// Adapts Node's sync sqlite API to Kysely.
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
import type { DatabaseSync, SQLInputValue, StatementSync } from "node:sqlite";
import type {
DatabaseConnection,
DatabaseIntrospector,
@@ -13,11 +13,15 @@ import type {
} from "kysely";
import {
CompiledQuery,
DeleteQueryNode,
IdentifierNode,
InsertQueryNode,
RawNode,
SelectQueryNode,
SqliteAdapter,
SqliteIntrospector,
SqliteQueryCompiler,
UpdateQueryNode,
createQueryId,
} from "kysely";
@@ -150,26 +154,7 @@ class NodeSqliteKyselyConnection implements DatabaseConnection {
}
executeQuery<O>(compiledQuery: CompiledQuery): Promise<QueryResult<O>> {
const { sql, parameters } = compiledQuery;
const stmt = this.#db.prepare(sql);
const sqliteParameters = parameters as SQLInputValue[];
if (stmt.columns().length > 0) {
return Promise.resolve({ rows: stmt.all(...sqliteParameters) as O[] });
}
const { changes, lastInsertRowid } = stmt.run(...sqliteParameters);
const baseResult: QueryResult<O> = {
numAffectedRows: BigInt(changes),
rows: [],
};
if (isInsertStatement(sql) && changes > 0) {
return Promise.resolve({
...baseResult,
insertId: BigInt(lastInsertRowid),
});
}
return Promise.resolve(baseResult);
return Promise.resolve(executeCompiledQuerySync<O>(this.#db, compiledQuery));
}
async *streamQuery<O>(
@@ -185,8 +170,47 @@ class NodeSqliteKyselyConnection implements DatabaseConnection {
}
}
function isInsertStatement(sql: string): boolean {
return sql.trimStart().toLowerCase().startsWith("insert");
/** Execute a compiled Kysely query synchronously against node:sqlite. */
export function executeCompiledQuerySync<O>(
db: DatabaseSync,
compiledQuery: CompiledQuery,
): QueryResult<O> {
const statement = db.prepare(compiledQuery.sql);
const parameters = compiledQuery.parameters as SQLInputValue[];
if (statementReturnsRows(statement, compiledQuery)) {
return { rows: statement.all(...parameters) as O[] };
}
const { changes, lastInsertRowid } = statement.run(...parameters);
const result: QueryResult<O> = {
numAffectedRows: BigInt(changes),
rows: [],
};
if (InsertQueryNode.is(compiledQuery.query) && changes > 0) {
return { ...result, insertId: BigInt(lastInsertRowid) };
}
return result;
}
// node:sqlite added StatementSync.columns() in v22.16/v23.11; it asks SQLite
// directly whether a prepared statement yields a result set. Node 23.023.10
// (still >=22.19, so allowed by engines) lack it, so fall back to the compiled
// Kysely node. That is exact here: callers only execute builder queries through
// this dialect, and the dialect itself only raw-executes transaction-control
// statements (begin/commit/rollback/savepoint), none of which return rows.
function statementReturnsRows(statement: StatementSync, compiledQuery: CompiledQuery): boolean {
if (typeof statement.columns === "function") {
return statement.columns().length > 0;
}
const node = compiledQuery.query;
if (SelectQueryNode.is(node)) {
return true;
}
if (InsertQueryNode.is(node) || UpdateQueryNode.is(node) || DeleteQueryNode.is(node)) {
return node.returning != null;
}
return false;
}
function createSavepointCommand(command: string, savepointName: string): RawNode {

View File

@@ -1,8 +1,8 @@
// Adapts node:sqlite sync database calls for Kysely-style query execution.
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
import type { DatabaseSync } from "node:sqlite";
import type { CompiledQuery, Kysely, QueryResult } from "kysely";
import { InsertQueryNode, Kysely as KyselyInstance } from "kysely";
import { NodeSqliteKyselyDialect } from "./kysely-node-sqlite.js";
import { Kysely as KyselyInstance } from "kysely";
import { NodeSqliteKyselyDialect, executeCompiledQuerySync } from "./kysely-node-sqlite.js";
// Sync query helpers execute compiled Kysely SQL against node:sqlite without
// going through Kysely's async driver path.
@@ -24,38 +24,12 @@ export function getNodeSqliteKysely<Database>(db: DatabaseSync): Kysely<Database
return kysely;
}
/** Execute a compiled Kysely query synchronously against node:sqlite. */
export function executeCompiledSqliteQuerySync<Row>(
db: DatabaseSync,
compiledQuery: CompiledQuery<Row>,
): QueryResult<Row> {
const statement = db.prepare(compiledQuery.sql);
const parameters = compiledQuery.parameters as SQLInputValue[];
if (statement.columns().length > 0) {
return { rows: statement.all(...parameters) as Row[] };
}
const { changes, lastInsertRowid } = statement.run(...parameters);
const result: QueryResult<Row> = {
numAffectedRows: BigInt(changes),
rows: [],
};
if (InsertQueryNode.is(compiledQuery.query) && changes > 0) {
return {
...result,
insertId: BigInt(lastInsertRowid),
};
}
return result;
}
/** Compile and execute a Kysely query synchronously. */
export function executeSqliteQuerySync<Row>(
db: DatabaseSync,
query: CompilableQuery<Row>,
): QueryResult<Row> {
return executeCompiledSqliteQuerySync<Row>(db, query.compile());
return executeCompiledQuerySync<Row>(db, query.compile());
}
/** Execute a Kysely query synchronously and return its first row. */