/** * Port of `@effect/sql-sqlite-node` that uses the native `node:sqlite` * bindings instead of `better-sqlite3`. * * @module SqliteClient */ import * as NodeSqlite from "node:sqlite"; import * as Cache from "effect/Cache"; import * as Config from "effect/Config"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import { identity } from "effect/Function"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Context from "effect/Context"; import * as Stream from "effect/Stream"; import * as Reactivity from "effect/unstable/reactivity/Reactivity"; import * as Client from "effect/unstable/sql/SqlClient"; import type { Connection } from "effect/unstable/sql/SqlConnection"; import { SqlError, classifySqliteError } from "effect/unstable/sql/SqlError"; import * as Statement from "effect/unstable/sql/Statement"; const ATTR_DB_SYSTEM_NAME = "db.system.name"; export const TypeId: TypeId = "~local/sqlite-node/SqliteClient"; export type TypeId = "~local/sqlite-node/SqliteClient"; export interface SqliteClientConfig { readonly filename: string; readonly readonly?: boolean | undefined; readonly allowExtension?: boolean | undefined; readonly prepareCacheSize?: number | undefined; readonly prepareCacheTTL?: Duration.Input | undefined; readonly spanAttributes?: Record | undefined; readonly transformResultNames?: ((str: string) => string) | undefined; readonly transformQueryNames?: ((str: string) => string) | undefined; } export interface SqliteMemoryClientConfig extends Omit< SqliteClientConfig, "filename" | "readonly" > {} export class UnsupportedNodeSqliteVersionError extends Schema.TaggedError()( "UnsupportedNodeSqliteVersionError", { nodeVersion: Schema.String, requirement: Schema.String, }, ) { override get message(): string { return `Node.js ${this.nodeVersion} is missing required node:sqlite APIs. Upgrade to ${this.requirement}.`; } } export class UnsupportedNodeSqliteOperationError extends Schema.TaggedError()( "UnsupportedNodeSqliteOperationError", {}, ) { override get message(): string { return "Node SQLite does not support executeStream."; } } /** * Verify that the current Node.js version includes the `node:sqlite` APIs * used by `NodeSqliteClient` — specifically `StatementSync.columns()` (added * in Node 22.16.0 / 23.11.0). * * @see https://github.com/nodejs/node/pull/57490 */ const checkNodeSqliteCompat = () => { const parts = process.versions.node.split(".").map(Number); const major = parts[0] ?? 0; const minor = parts[1] ?? 0; const supported = (major === 22 && minor >= 16) || (major === 23 && minor >= 11) || major >= 24; if (!supported) { return Effect.die( new UnsupportedNodeSqliteVersionError({ nodeVersion: process.versions.node, requirement: "Node.js >=22.16, >=23.11, or >=24", }), ); } return Effect.void; }; const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( options: SqliteClientConfig, openDatabase: () => NodeSqlite.DatabaseSync, ): Effect.fn.Return { yield* checkNodeSqliteCompat(); const compiler = Statement.makeCompilerSqlite(options.transformQueryNames); const transformRows = options.transformResultNames ? Statement.defaultTransforms(options.transformResultNames).array : undefined; const makeConnection = Effect.gen(function* () { const scope = yield* Effect.scope; const db = yield* Effect.try({ try: openDatabase, catch: (cause) => new SqlError({ reason: classifySqliteError(cause, { message: "Failed to open database", operation: "open", }), }), }); yield* Scope.addFinalizer( scope, Effect.try({ try: () => db.close(), catch: (cause) => new SqlError({ reason: classifySqliteError(cause, { message: "Failed to close database", operation: "close", }), }), }).pipe(Effect.orDie), ); const statementReaderCache = new WeakMap(); const hasRows = (statement: NodeSqlite.StatementSync): boolean => { const cached = statementReaderCache.get(statement); if (cached !== undefined) { return cached; } const value = statement.columns().length > 0; statementReaderCache.set(statement, value); return value; }; const prepare = (sql: string) => Effect.try({ try: () => db.prepare(sql), catch: (cause) => new SqlError({ reason: classifySqliteError(cause, { message: "Failed to prepare statement", operation: "prepare", }), }), }); const prepareCache = yield* Cache.make({ capacity: options.prepareCacheSize ?? 200, timeToLive: options.prepareCacheTTL ?? Duration.minutes(10), lookup: prepare, }); const runStatement = ( statement: NodeSqlite.StatementSync, params: ReadonlyArray, raw: boolean, ) => Effect.withFiber, SqlError>((fiber) => { try { statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers))); if (hasRows(statement)) { return Effect.succeed(statement.all(...(params as any))); } const result = statement.run(...(params as any)); return Effect.succeed(raw ? (result as unknown as ReadonlyArray) : []); } catch (cause) { return Effect.fail( new SqlError({ reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute", }), }), ); } }); const run = (sql: string, params: ReadonlyArray, raw = false) => Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw)); const runStatementValues = ( statement: NodeSqlite.StatementSync, params: ReadonlyArray, ) => Effect.acquireUseRelease( Effect.succeed(statement), (statement) => Effect.try({ try: () => { if (hasRows(statement)) { statement.setReturnArrays(true); // Safe to cast to array after we've setReturnArrays(true) return statement.all(...(params as any)) as unknown as ReadonlyArray< ReadonlyArray >; } statement.run(...(params as any)); return []; }, catch: (cause) => new SqlError({ reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute", }), }), }), (statement) => Effect.try({ try: () => { if (hasRows(statement)) { statement.setReturnArrays(false); } }, catch: (cause) => new SqlError({ reason: classifySqliteError(cause, { message: "Failed to reset statement result mode", operation: "resetResultMode", }), }), }).pipe(Effect.orDie), ); const runValues = (sql: string, params: ReadonlyArray) => Effect.flatMap(Cache.get(prepareCache, sql), (statement) => runStatementValues(statement, params), ); return identity({ execute(sql, params, rowTransform) { return rowTransform ? Effect.map(run(sql, params), rowTransform) : run(sql, params); }, executeRaw(sql, params) { return run(sql, params, true); }, executeValues(sql, params) { return runValues(sql, params); }, executeValuesUnprepared(sql, params) { return Effect.flatMap(prepare(sql), (statement) => runStatementValues(statement, params ?? []), ); }, executeUnprepared(sql, params, rowTransform) { const effect = prepare(sql).pipe( Effect.flatMap((statement) => runStatement(statement, params ?? [], false)), ); return rowTransform ? Effect.map(effect, rowTransform) : effect; }, executeStream(_sql, _params) { return Stream.die(new UnsupportedNodeSqliteOperationError()); }, }); }); const semaphore = yield* Semaphore.make(1); const connection = yield* makeConnection; const acquirer = semaphore.withPermits(1)(Effect.succeed(connection)); const transactionAcquirer = Effect.uninterruptibleMask((restore) => { const fiber = Fiber.getCurrent()!; const scope = Context.getUnsafe(fiber.context, Scope.Scope); return Effect.as( Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))), connection, ); }); return yield* Client.make({ acquirer, compiler, transactionAcquirer, spanAttributes: [ ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []), [ATTR_DB_SYSTEM_NAME, "sqlite"], ], transformRows, }); }); const make = ( options: SqliteClientConfig, ): Effect.Effect => makeWithDatabase( options, () => new NodeSqlite.DatabaseSync(options.filename, { readOnly: options.readonly ?? false, allowExtension: options.allowExtension ?? false, }), ); const makeMemory = ( config: SqliteMemoryClientConfig = {}, ): Effect.Effect => makeWithDatabase( { ...config, filename: ":memory:", readonly: false, }, () => { const database = new NodeSqlite.DatabaseSync(":memory:", { allowExtension: config.allowExtension ?? false, }); return database; }, ); export const layerConfig = ( config: Config.Wrap, ): Layer.Layer => Layer.effect(Client.SqlClient, Config.unwrap(config).pipe(Effect.flatMap(make))).pipe( Layer.provide(Reactivity.layer), ); export const layer = (config: SqliteClientConfig): Layer.Layer => Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer)); export const layerMemory = ( config: SqliteMemoryClientConfig = {}, ): Layer.Layer => Layer.effect(Client.SqlClient, makeMemory(config)).pipe(Layer.provide(Reactivity.layer));