import { createRequire } from "node:module"; import path from "node:path"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; import * as ServiceMap from "effect/ServiceMap"; import * as SqlClient from "effect/unstable/sql/SqlClient"; export interface SqliteStatement { run: (...params: unknown[]) => unknown; get: (...params: unknown[]) => unknown; all: (...params: unknown[]) => unknown[]; } export interface SqliteDatabase { exec: (sql: string) => void; prepare: (sql: string) => SqliteStatement; close: () => void; } export interface EffectSqliteDatabaseAdapter extends SqliteDatabase { runWithSqlClient: (effect: Effect.Effect) => A; } interface SqliteDriverModule { SqliteClient?: { layer: (config: { filename: string }) => Layer.Layer; }; } function isNodeSqliteUnavailableError(error: unknown): boolean { if (!(error instanceof Error)) { return false; } const code = (error as NodeJS.ErrnoException).code; if (code === "ERR_UNKNOWN_BUILTIN_MODULE" || code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND") { return true; } return ( error.message.includes("node:sqlite") || error.message.includes("@effect/sql-sqlite-node") || error.message.includes("Only URLs with a scheme in: file, data, and node") ); } function normalizeStatementBatch(sql: string): string[] { const normalized = sql.replace(/\r\n/g, "\n"); const statements: string[] = []; let current = ""; let inSingleQuote = false; let inDoubleQuote = false; let inBacktick = false; for (const char of normalized) { if (char === "'" && !inDoubleQuote && !inBacktick) { inSingleQuote = !inSingleQuote; current += char; continue; } if (char === '"' && !inSingleQuote && !inBacktick) { inDoubleQuote = !inDoubleQuote; current += char; continue; } if (char === "`" && !inSingleQuote && !inDoubleQuote) { inBacktick = !inBacktick; current += char; continue; } if (char === ";" && !inSingleQuote && !inDoubleQuote && !inBacktick) { const statement = current.trim(); if (statement.length > 0) { statements.push(statement); } current = ""; continue; } current += char; } const trailing = current.trim(); if (trailing.length > 0) { statements.push(trailing); } return statements; } function toSafeInteger(value: unknown, fallback = 0): number { if (typeof value === "number" && Number.isFinite(value)) { return Math.trunc(value); } if (typeof value === "bigint") { return Number(value); } return fallback; } class EffectSqliteDatabase implements SqliteDatabase { private readonly scope: Scope.Closeable; private readonly services: ServiceMap.ServiceMap; private readonly sql: SqlClient.SqlClient; private closed = false; constructor(layer: Layer.Layer) { this.scope = Effect.runSync(Scope.make()); try { this.services = Effect.runSync(Layer.buildWithScope(layer, this.scope)); this.sql = Effect.runSync( Effect.provideServices(Effect.service(SqlClient.SqlClient), this.services), ); } catch (error) { try { Effect.runSync(Scope.close(this.scope, Exit.void)); } catch { // Best effort cleanup on failed adapter construction. } throw error; } } exec(sql: string): void { for (const statement of normalizeStatementBatch(sql)) { this.runEffect(this.sql.unsafe(statement).raw); } } prepare(sql: string): SqliteStatement { return { run: (...params: unknown[]) => this.runStatement(sql, params), get: (...params: unknown[]) => { const rows = this.queryStatement(sql, params); return rows[0]; }, all: (...params: unknown[]) => this.queryStatement(sql, params), }; } close(): void { if (this.closed) { return; } this.closed = true; Effect.runSync(Scope.close(this.scope, Exit.void)); } runWithSqlClient(effect: Effect.Effect): A { return this.runEffect(effect); } private runStatement(sql: string, params: ReadonlyArray): unknown { this.runEffect(this.sql.unsafe(sql, params).raw); const stats = this.runEffect( this.sql .unsafe<{ changes?: number | bigint; lastInsertRowid?: number | bigint }>( "SELECT changes() AS changes, last_insert_rowid() AS lastInsertRowid;", ) .unprepared, )[0]; return { changes: toSafeInteger(stats?.changes, 0), lastInsertRowid: toSafeInteger(stats?.lastInsertRowid, 0), }; } private queryStatement(sql: string, params: ReadonlyArray): unknown[] { return this.runEffect(this.sql.unsafe(sql, params).unprepared) as unknown[]; } private runEffect(effect: Effect.Effect): A { const provided = Effect.provideServices(effect, this.services) as Effect.Effect; return Effect.runSync(provided); } } function openDriverDatabase( requireFn: ReturnType, moduleId: string, dbPath: string, ): SqliteDatabase { const sqliteDriver = requireFn(moduleId) as SqliteDriverModule; const layer = sqliteDriver.SqliteClient?.layer; if (typeof layer !== "function") { throw new Error(`${moduleId} was loaded but SqliteClient.layer is missing.`); } return new EffectSqliteDatabase(layer({ filename: dbPath })) satisfies EffectSqliteDatabaseAdapter; } function openNodeSqliteDatabase( requireFn: ReturnType, dbPath: string, ): SqliteDatabase { try { return openDriverDatabase(requireFn, "@effect/sql-sqlite-node", dbPath); } catch (error) { if (isNodeSqliteUnavailableError(error)) { throw new Error( "@effect/sql-sqlite-node is unavailable in this runtime. Ensure dependencies are installed and use Node.js 22+ (or run the server with Bun).", { cause: error }, ); } throw error; } } function openBunSqliteDatabase( requireFn: ReturnType, dbPath: string, ): SqliteDatabase { return openDriverDatabase(requireFn, "@effect/sql-sqlite-bun", dbPath); } export function openSqliteDatabase( dbPath: string, requireFn: ReturnType = createRequire( path.join(process.cwd(), "t3code-sqlite-adapter.cjs"), ), runtimeIsBun = Boolean(process.versions.bun), ): SqliteDatabase { if (runtimeIsBun) { // Development mode runs in Bun, so use Bun's SQLite adapter return openBunSqliteDatabase(requireFn, dbPath); } return openNodeSqliteDatabase(requireFn, dbPath); }