fix(infra): fail fast for sync sqlite facade execution

This commit is contained in:
Vincent Koc
2026-06-09 09:54:59 +09:00
parent 2c6bdc8b28
commit 80f1ae6ffe
2 changed files with 150 additions and 3 deletions
+80
View File
@@ -0,0 +1,80 @@
// Covers the compile-only Kysely facade used by sync node:sqlite helpers.
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it } from "vitest";
import {
clearNodeSqliteKyselyCacheForDatabase,
executeSqliteQuerySync,
getNodeSqliteKysely,
} from "./kysely-sync.js";
type SyncHelperTestDatabase = {
items: {
id: number;
name: string;
};
};
describe("kysely sync helpers", () => {
let database: DatabaseSync | undefined;
afterEach(() => {
if (!database) {
return;
}
clearNodeSqliteKyselyCacheForDatabase(database);
database.close();
database = undefined;
});
it("keeps the builder facade compile-only and fails direct execution", async () => {
database = new DatabaseSync(":memory:");
database.exec("create table items (id integer primary key, name text not null)");
const db = getNodeSqliteKysely<SyncHelperTestDatabase>(database);
const insertQuery = db.insertInto("items").values({ id: 1, name: "Ada" });
expect(insertQuery.compile().sql).toContain("insert into");
executeSqliteQuerySync(database, insertQuery);
expect(executeSqliteQuerySync(database, db.selectFrom("items").selectAll()).rows).toEqual([
{ id: 1, name: "Ada" },
]);
const compileOnlyError = /compile-only Kysely facade/;
await expect(db.selectFrom("items").selectAll().execute()).rejects.toThrow(compileOnlyError);
await expect(db.insertInto("items").values({ id: 2, name: "Grace" }).execute()).rejects.toThrow(
compileOnlyError,
);
await expect(
db.transaction().execute(async (trx) => {
await trx.insertInto("items").values({ id: 3, name: "Lin" }).execute();
}),
).rejects.toThrow(compileOnlyError);
await expectCompileOnlyRejection(db.startTransaction().execute());
await expectCompileOnlyRejection(consumeStream(db.selectFrom("items").selectAll().stream()));
await expectCompileOnlyRejection(db.selectFrom("items").selectAll().execute());
expect(
executeSqliteQuerySync(database, db.selectFrom("items").select(["id", "name"])).rows,
).toEqual([{ id: 1, name: "Ada" }]);
});
});
async function expectCompileOnlyRejection(promise: Promise<unknown>): Promise<void> {
await expect(Promise.race([promise, timeoutAfter(500)])).rejects.toThrow(
/compile-only Kysely facade/,
);
}
function timeoutAfter(ms: number): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error("timed out waiting for compile-only rejection")), ms);
});
}
async function consumeStream<Row>(stream: AsyncIterableIterator<Row>): Promise<Row[]> {
const rows: Row[] = [];
for await (const row of stream) {
rows.push(row);
}
return rows;
}
+70 -3
View File
@@ -1,7 +1,14 @@
// Adapts node:sqlite sync database calls for Kysely-style query execution.
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
import type { CompiledQuery, Kysely, QueryResult } from "kysely";
import { InsertQueryNode, Kysely as KyselyInstance } from "kysely";
import type {
CompiledQuery,
DatabaseConnection,
Driver,
Kysely,
QueryResult,
TransactionSettings,
} from "kysely";
import { InsertQueryNode, Kysely as KyselyInstance, SqliteAdapter } from "kysely";
import { NodeSqliteKyselyDialect } from "./kysely-node-sqlite.js";
// Sync query helpers execute compiled Kysely SQL against node:sqlite without
@@ -18,7 +25,7 @@ export function getNodeSqliteKysely<Database>(db: DatabaseSync): Kysely<Database
return existing as Kysely<Database>;
}
const kysely = new KyselyInstance<Database>({
dialect: new NodeSqliteKyselyDialect({ database: db }),
dialect: new CompileOnlyNodeSqliteKyselyDialect(),
});
kyselyByDatabase.set(db, kysely as Kysely<unknown>);
return kysely;
@@ -70,3 +77,63 @@ export function executeSqliteQueryTakeFirstSync<Row>(
export function clearNodeSqliteKyselyCacheForDatabase(db: DatabaseSync): void {
kyselyByDatabase.delete(db);
}
class CompileOnlyNodeSqliteKyselyDialect extends NodeSqliteKyselyDialect {
constructor() {
super({ database: createUnavailableDatabase });
}
override createDriver(): Driver {
return new CompileOnlySqliteDriver();
}
override createAdapter(): SqliteAdapter {
return new CompileOnlySqliteAdapter();
}
}
class CompileOnlySqliteDriver implements Driver {
async init(): Promise<void> {}
async acquireConnection(): Promise<DatabaseConnection> {
throw createCompileOnlyExecutionError();
}
async beginTransaction(
_connection: DatabaseConnection,
_settings: TransactionSettings,
): Promise<void> {
throw createCompileOnlyExecutionError();
}
async commitTransaction(_connection: DatabaseConnection): Promise<void> {
throw createCompileOnlyExecutionError();
}
async rollbackTransaction(_connection: DatabaseConnection): Promise<void> {
throw createCompileOnlyExecutionError();
}
async releaseConnection(_connection: DatabaseConnection): Promise<void> {}
async destroy(): Promise<void> {}
}
function createCompileOnlyExecutionError(): Error {
return new Error(
"getNodeSqliteKysely() returns a compile-only Kysely facade; use executeSqliteQuerySync() to execute node:sqlite queries.",
);
}
function createUnavailableDatabase(): never {
throw createCompileOnlyExecutionError();
}
class CompileOnlySqliteAdapter extends SqliteAdapter {
override get supportsMultipleConnections(): boolean {
// Kysely's SQLite adapter installs a single-connection mutex. This facade
// never opens a real connection, so direct execution should reject from
// acquisition without leaving controlled transaction calls wedged.
return true;
}
}