diff --git a/Makefile b/Makefile index 57b5949..e632c4b 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ clean-repos: rm -rf repos/ # Run all tests in Docker -test: test-deno-2.7 +test: test-node22 test-node24 test-deno-2.6 test-deno-2.7 # Run Node + Vitest tests (Drizzle shared suite) test-node22: diff --git a/README.md b/README.md index b6c2d22..8ce4db8 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,21 @@ const db = drizzle(client.run, client.batch, { schema: { users } }); client.db.exec("PRAGMA journal_mode = WAL"); ``` +### Drizzle Kit Driver (Experimental) + +A drop-in replacement for better-sqlite3 in drizzle-kit's connections is available: + +```ts +import { createDrizzleKitDriver } from "@hotsauce/drizzle-runtime-sqlite/kit"; + +const driver = await createDrizzleKitDriver({ url: "./database.db" }); + +// Returns drizzle-kit compatible interface: +// { query, run, proxy, transactionProxy, migrate, packageName: "node:sqlite" } +``` + +> **Note:** This driver is intended for integration into drizzle-kit itself and is not currently tested independently. Use at your own risk. + ## Requirements - Deno 2.6.0+ (first version with `stmt.columns()` support) diff --git a/deno.jsonc b/deno.jsonc index 4b5cc59..8200a40 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,11 +1,16 @@ { "name": "@hotsauce/drizzle-runtime-sqlite", - "version": "0.1.0", - "exports": "./mod.ts", + "version": "0.1.1", + "exports": { + ".": "./mod.ts", + "./kit": "./src/kit.ts" + }, "imports": { "drizzle-orm": "npm:drizzle-orm@0.45.1", "drizzle-orm/sqlite-core": "npm:drizzle-orm@0.45.1/sqlite-core", "drizzle-orm/sqlite-proxy": "npm:drizzle-orm@0.45.1/sqlite-proxy", + "drizzle-orm/migrator": "npm:drizzle-orm@0.45.1/migrator", + "drizzle-orm/sqlite-proxy/migrator": "npm:drizzle-orm@0.45.1/sqlite-proxy/migrator", "vitest": "npm:vitest@^1.6.0", "vitest/config": "npm:vitest@^1.6.0/config" }, diff --git a/src/callback.ts b/src/callback.ts index 0f2bf57..14e937a 100644 --- a/src/callback.ts +++ b/src/callback.ts @@ -15,23 +15,7 @@ import type { ProxyResult, SqliteOptions, } from "./types.ts"; - -/** - * Extended StatementSync interface for newer Node.js APIs not yet in Deno's types. - * These APIs exist in Node 22.16+/24+. - */ -interface ExtendedStatementMethods { - columns(): { name: string }[]; - setReturnArrays(enabled: boolean): void; - setAllowUnknownNamedParameters(enabled: boolean): void; -} - -/** - * Cast a StatementSync to include extended methods. - */ -function asExtended(stmt: StatementSync): ExtendedStatementMethods { - return stmt as unknown as ExtendedStatementMethods; -} +import { asExtended, executeWithArrayMode } from "./shared.ts"; /** * Apply statement options from SqliteOptions to a prepared statement. @@ -53,25 +37,6 @@ function applyStatementOptions( } } -/** - * Convert an object row to an array using column order from statement metadata. - * Note: This doesn't work correctly for joins with duplicate column names. - */ -function rowToArray( - row: Record, - columns: { name: string }[], -): unknown[] { - return columns.map((col) => row[col.name]); -} - -/** - * Check if the statement supports setReturnArrays (Node 22.16+/24+) - */ -function hasArrayMode(stmt: StatementSync): boolean { - return typeof (stmt as unknown as ExtendedStatementMethods) - .setReturnArrays === "function"; -} - /** * Execute a prepared statement based on the method type. * @@ -85,61 +50,8 @@ function executeStatement( params: unknown[], method: ProxyMethod, ): ProxyResult { - // Try to use array mode if available (Node 22.16+/24+) - const extStmt = asExtended(stmt); - const useArrayMode = hasArrayMode(stmt); - if (useArrayMode) { - extStmt.setReturnArrays(true); - } - const typedParams = params as SupportedValueType[]; - - switch (method) { - case "run": { - stmt.run(...typedParams); - return { rows: [] }; - } - case "get": { - const row = stmt.get(...typedParams) as - | unknown[] - | Record - | undefined; - if (row === undefined) { - return { rows: undefined }; - } - if (useArrayMode) { - // Already an array - return { rows: row as unknown[] }; - } - // Convert object to array using column metadata - const columns = extStmt.columns(); - return { rows: rowToArray(row as Record, columns) }; - } - case "all": - case "values": { - const rows = stmt.all(...typedParams) as unknown[][] | Record< - string, - unknown - >[]; - if (rows.length === 0) { - return { rows: [] }; - } - if (useArrayMode) { - // Already arrays - return { rows: rows as unknown[][] }; - } - // Convert objects to arrays using column metadata - const columns = extStmt.columns(); - return { - rows: (rows as Record[]).map((row) => - rowToArray(row, columns) - ), - }; - } - default: { - throw new Error(`Unknown method: ${method}`); - } - } + return executeWithArrayMode(stmt, typedParams, method) as ProxyResult; } /** diff --git a/src/kit.ts b/src/kit.ts new file mode 100644 index 0000000..34ebd5c --- /dev/null +++ b/src/kit.ts @@ -0,0 +1,214 @@ +/** + * Drizzle Kit driver for node:sqlite + * + * Provides a drop-in replacement for better-sqlite3 in drizzle-kit's connections. + */ + +import { DatabaseSync } from "node:sqlite"; +import type { MigrationConfig } from "drizzle-orm/migrator"; +import { + asExtended, + executeWithArrayMode, + hasArrayMode, + normaliseSQLiteUrl, + prepareSqliteParams, + rowToArray, +} from "./shared.ts"; + +/** SQLiteDB interface matching drizzle-kit's expectations */ +export interface SQLiteDB { + query: (sql: string, params?: unknown[]) => Promise; + run: (query: string) => Promise; +} + +/** + * Proxy params matching drizzle-kit's ProxyParams. + * This is a practical subset - drizzle-kit's full type has mode/method required, + * but SQLite proxy paths don't always provide them. + */ +export interface ProxyParams { + sql: string; + params?: unknown[]; + method?: "run" | "all" | "get" | "values" | "execute"; + mode?: "array" | "object"; +} + +/** Proxy function type matching drizzle-kit */ +export type Proxy = (params: ProxyParams) => Promise; + +/** Transaction proxy params */ +export interface TransactionQuery { + sql: string; + method?: "run" | "all" | "get" | "values"; +} + +/** Transaction proxy function type matching drizzle-kit */ +export type TransactionProxy = ( + queries: TransactionQuery[], +) => Promise<(unknown[] | Error)[]>; + +/** Credentials for SQLite connection */ +export interface SqliteCredentials { + url: string; +} + +/** Return type of createDrizzleKitDriver */ +export interface DrizzleKitDriver extends SQLiteDB { + packageName: "node:sqlite"; + proxy: Proxy; + transactionProxy: TransactionProxy; + migrate: (config: MigrationConfig) => Promise; +} + +/** + * Create a Drizzle Kit compatible driver using node:sqlite. + * + * This provides the same interface as drizzle-kit's better-sqlite3 driver, + * allowing it to be used as a drop-in replacement. + * + * @param credentials - SQLite credentials with url property + * @returns Driver object compatible with drizzle-kit + * + * @example + * ```ts + * import { createDrizzleKitDriver } from "@hotsauce/drizzle-runtime-sqlite/kit"; + * + * const driver = await createDrizzleKitDriver({ url: "./database.db" }); + * // Use with drizzle-kit internals + * ``` + */ +export async function createDrizzleKitDriver( + credentials: SqliteCredentials, +): Promise { + const { drizzle } = await import("drizzle-orm/sqlite-proxy"); + const { migrate } = await import("drizzle-orm/sqlite-proxy/migrator"); + + const sqlite = new DatabaseSync(normaliseSQLiteUrl(credentials.url)); + + /** + * Callback for sqlite-proxy following its expected semantics. + * Note: This is only used internally for the migration drizzle instance. + * Migrations use "run" method, so get/all semantics don't matter in practice, + * but we keep them correct for safety. + */ + const sqliteProxyCallback = ( + sql: string, + params: unknown[], + method: "run" | "all" | "get" | "values", + ): { rows: unknown[] | unknown[][] | undefined } => { + const stmt = sqlite.prepare(sql); + return executeWithArrayMode(stmt, params, method); + }; + + // Create drizzle instance for migrations (internal use only) + // Note: drizzle's AsyncRemoteCallback expects { rows: any[] }, but sqlite-proxy + // semantics return undefined for get() with no row. We coerce here since + // migrations only use "run" method which always returns { rows: [] }. + const drzl = drizzle((sql, params, method) => { + const result = sqliteProxyCallback(sql, params as unknown[], method); + return Promise.resolve({ + rows: result.rows ?? [], + }); + }); + + const migrateFn = (config: MigrationConfig): Promise => { + return migrate( + drzl, + (queries) => { + for (const query of queries) { + sqliteProxyCallback(query, [], "run"); + } + return Promise.resolve(); + }, + config, + ); + }; + + const db: SQLiteDB = { + query: (sql: string, params: unknown[] = []): Promise => { + const stmt = sqlite.prepare(sql); + return Promise.resolve( + stmt.all(...(params as Parameters)) as T[], + ); + }, + run: (query: string): Promise => { + sqlite.prepare(query).run(); + return Promise.resolve(); + }, + }; + + const proxy: Proxy = (params: ProxyParams): Promise => { + const preparedParams = prepareSqliteParams(params.params || []); + const stmt = sqlite.prepare(params.sql); + + const method = params.method ?? "all"; + + if (method === "values" || method === "get" || method === "all") { + const useArrayMode = hasArrayMode(stmt); + if (useArrayMode && params.mode === "array") { + asExtended(stmt).setReturnArrays(true); + } + + const rows = stmt.all( + ...(preparedParams as Parameters), + ) as unknown[][] | Record[]; + + if (params.mode === "array" && !useArrayMode && rows.length > 0) { + // Fallback: convert objects to arrays using column metadata + const columns = asExtended(stmt).columns(); + return Promise.resolve( + (rows as Record[]).map((row) => + rowToArray(row, columns) + ), + ); + } + + return Promise.resolve(rows); + } + + stmt.run(...(preparedParams as Parameters)); + return Promise.resolve([]); + }; + + const transactionProxy: TransactionProxy = ( + queries: TransactionQuery[], + ): Promise<(unknown[] | Error)[]> => { + const results: (unknown[] | Error)[] = []; + + try { + sqlite.exec("BEGIN"); + + for (const query of queries) { + const stmt = sqlite.prepare(query.sql); + const method = query.method ?? "all"; + + let result: unknown[] = []; + if (method === "values" || method === "get" || method === "all") { + result = stmt.all() as unknown[]; + } else { + stmt.run(); + } + results.push(result); + } + + sqlite.exec("COMMIT"); + } catch (error) { + try { + sqlite.exec("ROLLBACK"); + } catch { + // Ignore rollback errors + } + results.push(error as Error); + } + + return Promise.resolve(results); + }; + + return { + ...db, + packageName: "node:sqlite", + proxy, + transactionProxy, + migrate: migrateFn, + }; +} diff --git a/src/shared.ts b/src/shared.ts new file mode 100644 index 0000000..8e2a1d7 --- /dev/null +++ b/src/shared.ts @@ -0,0 +1,179 @@ +/** + * Shared utilities for node:sqlite operations + * + * Common code used by both callback.ts (drizzle-orm) and kit.ts (drizzle-kit). + */ + +import type { StatementSync } from "node:sqlite"; + +/** + * Extended StatementSync interface for newer Node.js APIs. + * These APIs exist in Node 22.16+/24+ and Deno 2.7+. + */ +export interface ExtendedStatementMethods { + columns(): { name: string }[]; + setReturnArrays(enabled: boolean): void; + setAllowUnknownNamedParameters(enabled: boolean): void; +} + +/** + * Cast a StatementSync to include extended methods. + */ +export function asExtended(stmt: StatementSync): ExtendedStatementMethods { + return stmt as unknown as ExtendedStatementMethods; +} + +/** + * Check if the statement supports setReturnArrays (Node 22.16+/24+, Deno 2.7+) + */ +export function hasArrayMode(stmt: StatementSync): boolean { + return typeof (stmt as unknown as ExtendedStatementMethods) + .setReturnArrays === + "function"; +} + +/** + * Check if the statement supports columns() metadata (Node 22.5.0+, Deno 2.6+) + */ +export function hasColumns(stmt: StatementSync): boolean { + return typeof (stmt as unknown as ExtendedStatementMethods).columns === + "function"; +} + +/** + * Convert an object row to an array using column order from statement metadata. + * Note: This doesn't work correctly for joins with duplicate column names. + */ +export function rowToArray( + row: Record, + columns: { name: string }[], +): unknown[] { + return columns.map((col) => row[col.name]); +} + +/** + * Execute a statement and return results as arrays. + * Handles the complexity of runtime detection and fallback conversion. + * + * @param stmt - Prepared statement + * @param params - Parameters to bind + * @param method - Query method type + * @returns Results with rows as arrays + */ +export function executeWithArrayMode( + stmt: StatementSync, + params: unknown[], + method: "run" | "all" | "get" | "values", +): { rows: unknown[] | unknown[][] | undefined } { + const extStmt = asExtended(stmt); + const useArrayMode = hasArrayMode(stmt); + + if (useArrayMode) { + extStmt.setReturnArrays(true); + } + + switch (method) { + case "run": { + stmt.run(...(params as Parameters)); + return { rows: [] }; + } + case "get": { + const row = stmt.get(...(params as Parameters)) as + | unknown[] + | Record + | undefined; + if (row === undefined) { + return { rows: undefined }; + } + if (useArrayMode) { + return { rows: row as unknown[] }; + } + // Fallback: convert object row to array using column metadata + if (!hasColumns(stmt)) { + throw new Error( + "node:sqlite stmt.columns() not available. Requires Node.js 22.5.0+ or Deno 2.6+.", + ); + } + const columns = extStmt.columns(); + return { rows: rowToArray(row as Record, columns) }; + } + case "all": + case "values": { + const rows = stmt.all(...(params as Parameters)) as + | unknown[][] + | Record[]; + if (rows.length === 0) { + return { rows: [] }; + } + if (useArrayMode) { + return { rows: rows as unknown[][] }; + } + // Fallback: convert object rows to arrays using column metadata + if (!hasColumns(stmt)) { + throw new Error( + "node:sqlite stmt.columns() not available. Requires Node.js 22.5.0+ or Deno 2.6+.", + ); + } + const columns = extStmt.columns(); + return { + rows: (rows as Record[]).map((row) => + rowToArray(row, columns) + ), + }; + } + default: + throw new Error(`Unknown method: ${method}`); + } +} + +/** + * Prepare SQLite params, converting binary objects to byte arrays. + * Matches drizzle-kit's prepareSqliteParams behavior. + */ +export function prepareSqliteParams(params: unknown[]): unknown[] { + return params.map((param) => { + if ( + param && + typeof param === "object" && + "type" in param && + "value" in param && + (param as { type: string }).type === "binary" + ) { + const value = typeof (param as { value: unknown }).value === "object" + ? JSON.stringify((param as { value: unknown }).value) + : ((param as { value: unknown }).value as string); + // Use TextEncoder for cross-runtime compatibility (works in Node, Deno, browsers) + return new TextEncoder().encode(value); + } + return param; + }); +} + +/** + * Normalise SQLite URL for node:sqlite. + * Handles file: URLs and plain paths. + * + * - `file:./relative.db` -> `./relative.db` + * - `file:///absolute/path.db` -> `/absolute/path.db` + * - `file://localhost/path.db` -> `/path.db` + * - `./relative.db` -> `./relative.db` (unchanged) + */ +export function normaliseSQLiteUrl(url: string): string { + if (!url.startsWith("file:")) { + return url; + } + + // Handle file:// URLs (with authority) + if (url.startsWith("file://")) { + try { + const parsed = new URL(url); + return parsed.pathname; + } catch { + // If URL parsing fails, strip prefix and return + return url.slice(7); + } + } + + // Handle file: prefix without // (e.g., file:./db.sqlite) + return url.slice(5); +}