/** * Port of `@effect/sql-sqlite-node` that uses the native `node:sqlite` * bindings instead of `better-sqlite3`. * * @module SqliteClient */ import { DatabaseSync, type StatementSync } 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 Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as ServiceMap from "effect/ServiceMap"; 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 } 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"; /** * SqliteClient - Effect service tag for the sqlite SQL client. */ export const SqliteClient = ServiceMap.Service("t3/persistence/NodeSqliteClient"); 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" > {} const makeWithDatabase = ( options: SqliteClientConfig, openDatabase: () => DatabaseSync, ): Effect.Effect => Effect.gen(function* () { 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 = openDatabase(); yield* Scope.addFinalizer( scope, Effect.sync(() => db.close()), ); const statementReaderCache = new WeakMap(); const hasRows = (statement: 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 prepareCache = yield* Cache.make({ capacity: options.prepareCacheSize ?? 200, timeToLive: options.prepareCacheTTL ?? Duration.minutes(10), lookup: (sql: string) => Effect.try({ try: () => db.prepare(sql), catch: (cause) => new SqlError({ cause, message: "Failed to prepare statement" }), }), }); const runStatement = ( statement: StatementSync, params: ReadonlyArray, raw: boolean, ) => Effect.withFiber, SqlError>((fiber) => { statement.setReadBigInts(Boolean(ServiceMap.get(fiber.services, Client.SafeIntegers))); try { 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({ cause, message: "Failed to execute statement" })); } }); const run = (sql: string, params: ReadonlyArray, raw = false) => Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw)); const runValues = (sql: string, params: ReadonlyArray) => Effect.acquireUseRelease( Cache.get(prepareCache, sql), (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({ cause, message: "Failed to execute statement" }), }), (statement) => Effect.sync(() => { if (hasRows(statement)) { statement.setReturnArrays(false); } }), ); 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); }, executeUnprepared(sql, params, rowTransform) { const effect = runStatement(db.prepare(sql), params ?? [], false); return rowTransform ? Effect.map(effect, rowTransform) : effect; }, executeStream(_sql, _params) { return Stream.die("executeStream not implemented"); }, }); }); 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 = ServiceMap.getUnsafe(fiber.services, 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 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 DatabaseSync(":memory:", { allowExtension: config.allowExtension ?? false, }); return database; }, ); export const layerConfig = ( config: Config.Wrap, ): Layer.Layer => Layer.effectServices( Config.unwrap(config) .asEffect() .pipe( Effect.flatMap(make), Effect.map((client) => ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), ), ), ).pipe(Layer.provide(Reactivity.layer)); export const layer = (config: SqliteClientConfig): Layer.Layer => Layer.effectServices( Effect.map(make(config), (client) => ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), ), ).pipe(Layer.provide(Reactivity.layer)); export const layerMemory = (config: SqliteMemoryClientConfig = {}): Layer.Layer => Layer.effectServices( Effect.map(makeMemory(config), (client) => ServiceMap.make(SqliteClient, client).pipe(ServiceMap.add(Client.SqlClient, client)), ), ).pipe(Layer.provide(Reactivity.layer));