1/**
2 * libSQL adapter for Effect SQL, backed by `@libsql/client`.
3 *
4 * This module provides a {@link LibsqlClient} and the generic SQL client
5 * service for `@libsql/client`. It uses Effect SQL's SQLite compiler, supports
6 * managed SDK clients or caller-owned live clients, classifies libSQL and
7 * SQLite failures as `SqlError`s, and provides transaction support with
8 * savepoints. Streaming queries are not implemented by this driver.
9 *
10 * @since 4.0.0
11 */
12import * as Libsql from "@libsql/client"
13import * as Config from "effect/Config"
14import * as Context from "effect/Context"
15import * as Effect from "effect/Effect"
16import * as Exit from "effect/Exit"
17import * as Layer from "effect/Layer"
18import * as Option from "effect/Option"
19import * as Reactivity from "effect/reactivity/Reactivity"
20import * as Redacted from "effect/Redacted"
21import * as Scope from "effect/Scope"
22import * as Semaphore from "effect/Semaphore"
23import * as Client from "effect/sql/SqlClient"
24import type { Connection } from "effect/sql/SqlConnection"
25import { classifySqliteError, SqlError } from "effect/sql/SqlError"
26import * as Statement from "effect/sql/Statement"
27import * as Stream from "effect/Stream"
28
29const ATTR_DB_SYSTEM_NAME = "db.system.name"
30
31const classifyError = (cause: unknown, message: string, operation: string) =>
32 classifySqliteError(cause, { message, operation })
33
34/**
35 * Runtime type identifier used to mark `LibsqlClient` values.
36 *
37 * @category type IDs
38 * @since 4.0.0
39 */
40export const TypeId: TypeId = "~@effect/sql-libsql/LibsqlClient"
41
42/**
43 * Type-level identifier used to mark `LibsqlClient` values.
44 *
45 * @category type IDs
46 * @since 4.0.0
47 */
48export type TypeId = "~@effect/sql-libsql/LibsqlClient"
49
50/**
51 * libSQL-backed SQL client service, extending `SqlClient` with its runtime type marker and client configuration.
52 *
53 * @category services
54 * @since 4.0.0
55 */
56export interface LibsqlClient extends Client.SqlClient {
57 readonly [TypeId]: TypeId
58 readonly config: LibsqlClientConfig
59}
60
61/**
62 * Service tag for the libSQL client service.
63 *
64 * **When to use**
65 *
66 * Use to access or provide a libSQL client through the Effect context.
67 *
68 * @category services
69 * @since 4.0.0
70 */
71export const LibsqlClient = Context.Service<LibsqlClient>("@effect/sql-libsql/LibsqlClient")
72
73let clientIdCounter = 0
74
75/**
76 * Configuration for a libSQL client, either by supplying connection options or an existing live libSQL client.
77 *
78 * @category models
79 * @since 4.0.0
80 */
81export type LibsqlClientConfig = LibsqlClientConfig.Full | LibsqlClientConfig.Live
82
83/**
84 * Namespace containing the configuration variants for `LibsqlClient`.
85 *
86 * @since 4.0.0
87 */
88export declare namespace LibsqlClientConfig {
89 /**
90 * Shared libSQL client options for span attributes and query/result name transformations.
91 *
92 * @category models
93 * @since 4.0.0
94 */
95 export interface Base {
96 readonly spanAttributes?: Record<string, unknown> | undefined
97 readonly transformResultNames?: ((str: string) => string) | undefined
98 readonly transformQueryNames?: ((str: string) => string) | undefined
99 }
100
101 /**
102 * Connection-based libSQL configuration used to create a managed client, including URL, credentials, sync, integer mode, TLS, and concurrency options.
103 *
104 * @category models
105 * @since 4.0.0
106 */
107 export interface Full extends Base {
108 /**
109 * The database URL.
110 *
111 * **Details**
112 *
113 * The client supports `libsql:`, `http:`/`https:`, `ws:`/`wss:` and `file:` URL. For more infomation,
114 * please refer to the project README:
115 *
116 * https://github.com/libsql/libsql-client-ts#supported-urls
117 */
118 readonly url: string | URL
119 /** Authentication token for the database. */
120 readonly authToken?: Redacted.Redacted | undefined
121 /** Encryption key for the database. */
122 readonly encryptionKey?: Redacted.Redacted | undefined
123 /** URL of a remote server to synchronize database with. */
124 readonly syncUrl?: string | URL | undefined
125 /** Sync interval in seconds. */
126 readonly syncInterval?: number | undefined
127 /**
128 * Enables or disables TLS for `libsql:` URLs.
129 *
130 * **Details**
131 *
132 * By default, `libsql:` URLs use TLS. You can set this option to `false` to disable TLS.
133 */
134 readonly tls?: boolean | undefined
135 /**
136 * How to convert SQLite integers to JavaScript values.
137 *
138 * **Details**
139 *
140 * - `"number"` (default): returns SQLite integers as JavaScript `number`-s (double precision floats).
141 * `number` cannot precisely represent integers larger than 2^53-1 in absolute value, so attempting to read
142 * larger integers will throw a `RangeError`.
143 * - `"bigint"`: returns SQLite integers as JavaScript `bigint`-s (arbitrary precision integers). Bigints can
144 * precisely represent all SQLite integers.
145 * - `"string"`: returns SQLite integers as strings.
146 */
147 readonly intMode?: "number" | "bigint" | "string" | undefined
148 /**
149 * Concurrency limit.
150 *
151 * **Details**
152 *
153 * By default, the client performs up to 20 concurrent requests. You can set this option to a higher
154 * number to increase the concurrency limit or set it to 0 to disable concurrency limits completely.
155 */
156 readonly concurrency?: number | undefined
157 }
158
159 /**
160 * Configuration that uses an existing libSQL client. The supplied `liveClient` is caller-owned and is not closed by the Effect client.
161 *
162 * @stability unstable
163 * @category models
164 * @since 4.0.0
165 */
166 export interface Live extends Base {
167 readonly liveClient: Libsql.Client
168 }
169}
170
171interface LibsqlConnection extends Connection {
172 readonly beginTransaction: Effect.Effect<LibsqlConnection, SqlError>
173 readonly commit: Effect.Effect<void, SqlError>
174 readonly rollback: Effect.Effect<void, SqlError>
175}
176
177/**
178 * Creates a scoped libSQL SQL client with transaction support. When given connection options it creates and closes the SDK client; when given `liveClient`, the caller retains ownership.
179 *
180 * @category constructors
181 * @since 4.0.0
182 */
183export const make = (
184 options: LibsqlClientConfig
185): Effect.Effect<LibsqlClient, never, Scope.Scope | Reactivity.Reactivity> =>
186 Effect.gen(function*() {
187 const LibsqlTransaction = Context.Service<readonly [LibsqlConnection, counter: number]>(
188 `@effect/sql-libsql/LibsqlClient/LibsqlTransaction/${clientIdCounter++}`
189 )
190 const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
191 const transformRows = options.transformResultNames ?
192 Statement.defaultTransforms(
193 options.transformResultNames
194 ).array :
195 undefined
196
197 const spanAttributes: Array<[string, unknown]> = [
198 ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
199 [ATTR_DB_SYSTEM_NAME, "sqlite"]
200 ]
201
202 class LibsqlConnectionImpl implements LibsqlConnection {
203 readonly sdk: Libsql.Client | Libsql.Transaction
204 constructor(sdk: Libsql.Client | Libsql.Transaction) {
205 this.sdk = sdk
206 }
207
208 run(
209 sql: string,
210 params: ReadonlyArray<unknown> = []
211 ) {
212 return Effect.map(
213 Effect.tryPromise({
214 try: () => this.sdk.execute({ sql, args: params as Array<any> }),
215 catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to execute statement", "execute") })
216 }),
217 (results) => results.rows
218 )
219 }
220
221 runRaw(
222 sql: string,
223 params: ReadonlyArray<unknown> = []
224 ) {
225 return Effect.tryPromise({
226 try: () => this.sdk.execute({ sql, args: params as Array<any> }),
227 catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to execute statement", "execute") })
228 })
229 }
230
231 execute(
232 sql: string,
233 params: ReadonlyArray<unknown>,
234 transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
235 ) {
236 return transformRows
237 ? Effect.map(this.run(sql, params), transformRows)
238 : this.run(sql, params)
239 }
240 executeRaw(sql: string, params: ReadonlyArray<unknown>) {
241 return this.runRaw(sql, params)
242 }
243 executeValues(sql: string, params: ReadonlyArray<unknown>) {
244 return Effect.map(this.run(sql, params), (rows) => rows.map((row) => Array.from(row) as Array<any>))
245 }
246 executeValuesUnprepared(sql: string, params: ReadonlyArray<unknown>) {
247 return this.executeValues(sql, params)
248 }
249 executeUnprepared(
250 sql: string,
251 params: ReadonlyArray<unknown>,
252 transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
253 ) {
254 return this.execute(sql, params, transformRows)
255 }
256 executeStream() {
257 return Stream.die("executeStream not implemented")
258 }
259 get beginTransaction() {
260 return Effect.map(
261 Effect.tryPromise({
262 try: () => (this.sdk as Libsql.Client).transaction("write"),
263 catch: (cause) =>
264 new SqlError({ reason: classifyError(cause, "Failed to begin transaction", "beginTransaction") })
265 }),
266 (tx) => new LibsqlConnectionImpl(tx)
267 )
268 }
269 get commit() {
270 return Effect.tryPromise({
271 try: () => (this.sdk as Libsql.Transaction).commit(),
272 catch: (cause) =>
273 new SqlError({ reason: classifyError(cause, "Failed to commit transaction", "commitTransaction") })
274 })
275 }
276 get rollback() {
277 return Effect.tryPromise({
278 try: () => (this.sdk as Libsql.Transaction).rollback(),
279 catch: (cause) =>
280 new SqlError({ reason: classifyError(cause, "Failed to rollback transaction", "rollbackTransaction") })
281 })
282 }
283 }
284
285 const connection = "liveClient" in options
286 ? new LibsqlConnectionImpl(options.liveClient)
287 : yield* Effect.map(
288 Effect.acquireRelease(
289 Effect.sync(() =>
290 Libsql.createClient(
291 {
292 ...options,
293 authToken: Redacted.isRedacted(options.authToken)
294 ? Redacted.value(options.authToken)
295 : options.authToken,
296 encryptionKey: Redacted.isRedacted(options.encryptionKey)
297 ? Redacted.value(options.encryptionKey)
298 : options.encryptionKey,
299 url: options.url.toString(),
300 syncUrl: options.syncUrl?.toString()
301 } as Libsql.Config
302 )
303 ),
304 (sdk) => Effect.sync(() => sdk.close())
305 ),
306 (sdk) => new LibsqlConnectionImpl(sdk)
307 )
308 const semaphore = yield* Semaphore.make(1)
309
310 const withTransaction = Client.makeWithTransaction({
311 transactionService: LibsqlTransaction,
312 spanAttributes,
313 acquireConnection: Effect.uninterruptibleMask(Effect.fnUntraced(function*(restore) {
314 const scope = Scope.makeUnsafe()
315 yield* restore(semaphore.take(1))
316 yield* Scope.addFinalizer(scope, semaphore.release(1))
317 const conn = yield* connection.beginTransaction.pipe(
318 Effect.tapCause((cause) => Scope.close(scope, Exit.failCause(cause)))
319 )
320 return [scope, conn] as const
321 })),
322 begin: () => Effect.void, // already begun in acquireConnection
323 savepoint: (conn, id) => conn.executeRaw(`SAVEPOINT effect_sql_${id};`, []),
324 releaseSavepoint: (conn, id) => conn.executeRaw(`RELEASE SAVEPOINT effect_sql_${id};`, []),
325 commit: (conn) => conn.commit,
326 rollback: (conn) => conn.rollback,
327 rollbackSavepoint: (conn, id) => conn.executeRaw(`ROLLBACK TO SAVEPOINT effect_sql_${id};`, [])
328 })
329
330 const acquirer = Effect.flatMap(
331 Effect.serviceOption(LibsqlTransaction),
332 Option.match({
333 onNone: () => semaphore.withPermits(1)(Effect.succeed(connection as LibsqlConnection)),
334 onSome: ([conn]) => Effect.succeed(conn)
335 })
336 )
337
338 return Object.assign(
339 yield* Client.make({
340 acquirer,
341 transactionService: LibsqlTransaction as any,
342 compiler,
343 spanAttributes,
344 transformRows
345 }),
346 {
347 [TypeId]: TypeId as TypeId,
348 config: options,
349 withTransaction,
350 sdk: connection.sdk
351 }
352 )
353 })
354
355/**
356 * Creates a layer from a `Config`-wrapped libSQL client configuration, providing both `LibsqlClient` and `SqlClient`.
357 *
358 * @category layers
359 * @since 4.0.0
360 */
361export const layerConfig: (
362 config: Config.Wrap<LibsqlClientConfig>
363) => Layer.Layer<LibsqlClient | Client.SqlClient, Config.ConfigError> = (
364 config: Config.Wrap<LibsqlClientConfig>
365): Layer.Layer<LibsqlClient | Client.SqlClient, Config.ConfigError> =>
366 Layer.effectContext(
367 Config.unwrap(config).pipe(
368 Effect.flatMap(make),
369 Effect.map((client) =>
370 Context.make(LibsqlClient, client).pipe(
371 Context.add(Client.SqlClient, client)
372 )
373 )
374 )
375 ).pipe(Layer.provide(Reactivity.layer))
376
377/**
378 * Creates a layer from a concrete libSQL client configuration, providing both `LibsqlClient` and `SqlClient`.
379 *
380 * @category layers
381 * @since 4.0.0
382 */
383export const layer = (
384 config: LibsqlClientConfig
385): Layer.Layer<LibsqlClient | Client.SqlClient> =>
386 Layer.effectContext(
387 Effect.map(make(config), (client) =>
388 Context.make(LibsqlClient, client).pipe(
389 Context.add(Client.SqlClient, client)
390 ))
391 ).pipe(Layer.provide(Reactivity.layer))