From 80f1ae6ffe3e033def33f3852274e810fdc4c9e6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 9 Jun 2026 09:54:59 +0900 Subject: [PATCH] fix(infra): fail fast for sync sqlite facade execution --- src/infra/kysely-sync.test.ts | 80 +++++++++++++++++++++++++++++++++++ src/infra/kysely-sync.ts | 73 ++++++++++++++++++++++++++++++-- 2 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 src/infra/kysely-sync.test.ts diff --git a/src/infra/kysely-sync.test.ts b/src/infra/kysely-sync.test.ts new file mode 100644 index 000000000000..6cc3bfe25212 --- /dev/null +++ b/src/infra/kysely-sync.test.ts @@ -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(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): Promise { + await expect(Promise.race([promise, timeoutAfter(500)])).rejects.toThrow( + /compile-only Kysely facade/, + ); +} + +function timeoutAfter(ms: number): Promise { + return new Promise((_, reject) => { + setTimeout(() => reject(new Error("timed out waiting for compile-only rejection")), ms); + }); +} + +async function consumeStream(stream: AsyncIterableIterator): Promise { + const rows: Row[] = []; + for await (const row of stream) { + rows.push(row); + } + return rows; +} diff --git a/src/infra/kysely-sync.ts b/src/infra/kysely-sync.ts index d1bc6b4067ca..a39a447d12cc 100644 --- a/src/infra/kysely-sync.ts +++ b/src/infra/kysely-sync.ts @@ -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(db: DatabaseSync): Kysely; } const kysely = new KyselyInstance({ - dialect: new NodeSqliteKyselyDialect({ database: db }), + dialect: new CompileOnlyNodeSqliteKyselyDialect(), }); kyselyByDatabase.set(db, kysely as Kysely); return kysely; @@ -70,3 +77,63 @@ export function executeSqliteQueryTakeFirstSync( 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 {} + + async acquireConnection(): Promise { + throw createCompileOnlyExecutionError(); + } + + async beginTransaction( + _connection: DatabaseConnection, + _settings: TransactionSettings, + ): Promise { + throw createCompileOnlyExecutionError(); + } + + async commitTransaction(_connection: DatabaseConnection): Promise { + throw createCompileOnlyExecutionError(); + } + + async rollbackTransaction(_connection: DatabaseConnection): Promise { + throw createCompileOnlyExecutionError(); + } + + async releaseConnection(_connection: DatabaseConnection): Promise {} + + async destroy(): Promise {} +} + +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; + } +}