1import type { Client, InArgs, ResultSet, Row } from "@libsql/client";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { SQLite } from "./SQLite.ts";
5import type { SQLiteConnection } from "./SQLiteConnection.ts";
6import { parseError } from "./SQLiteError.ts";
7import type { SQLiteStatement } from "./SQLiteStatement.ts";
8
9/**
10 * Layer that provides the SQLite service using libsql.
11 *
12 * Note: This layer requires the @libsql/client package to be installed.
13 * Install with: bun add @libsql/client
14 */
15export const libSQL: Layer.Layer<SQLite> = Layer.sync(SQLite, () => ({
16 open: (path: string) =>
17 Effect.tryPromise({
18 try: async () => {
19 const { createClient } = await import("@libsql/client");
20 const url = path.startsWith("file:") ? path : `file:${path}`;
21 const client = createClient({ url });
22 // Enable WAL mode for better concurrent read performance
23 await client.execute("PRAGMA journal_mode = WAL;");
24 // Wait up to 30 seconds when database is locked before returning SQLITE_BUSY
25 await client.execute("PRAGMA busy_timeout = 30000;");
26 return fromClient(client);
27 },
28 catch: (e) =>
29 parseError(
30 extractErrorCode(e),
31 `Failed to open libsql database: ${e}`,
32 e,
33 ),
34 }),
35}));
36
37/**
38 * Create a SQLiteConnection from a libsql Client.
39 *
40 * @param executor - The executor to use for operations. In a transaction context,
41 * this should be the transaction object to avoid deadlocks.
42 */
43export const fromClient = (
44 client: Client,
45 executor: { execute: Client["execute"] } = client,
46): SQLiteConnection => ({
47 prepare: <R>(sql: string) => Effect.succeed(wrapStatement<R>(executor, sql)),
48
49 exec: (sql: string) =>
50 Effect.tryPromise({
51 try: () => executor.execute(sql),
52 catch: (e) =>
53 parseError(extractErrorCode(e), `Failed to execute SQL: ${e}`, e),
54 }),
55
56 transaction: <A, E>(
57 fn: (conn: SQLiteConnection) => Effect.Effect<A, E, never>,
58 ) =>
59 Effect.tryPromise({
60 try: async () => {
61 const tx = await client.transaction("write");
62 try {
63 // Create a connection that uses the transaction for all operations
64 const txConn = fromClient(client, tx);
65 const result = await Effect.runPromise(fn(txConn));
66 await tx.commit();
67 return result;
68 } catch (e) {
69 await tx.rollback();
70 throw e;
71 }
72 },
73 catch: (e) =>
74 parseError(extractErrorCode(e), `Transaction failed: ${e}`, e),
75 }) as Effect.Effect<
76 A,
77 E | import("./SQLiteError.ts").SQLiteErrorType,
78 never
79 >,
80
81 batch: (statements: Array<{ sql: string; params?: unknown[] }>) =>
82 Effect.tryPromise({
83 try: async () => {
84 // Use client.batch directly - it creates its own atomic transaction
85 await client.batch(
86 statements.map((stmt) => ({
87 sql: stmt.sql,
88 args: (stmt.params ?? []) as InArgs,
89 })),
90 "write",
91 );
92 },
93 catch: (e) =>
94 parseError(extractErrorCode(e), `Batch execution failed: ${e}`, e),
95 }),
96});
97
98/**
99 * Wrap a libsql statement in the SQLiteStatement interface.
100 *
101 * Unlike bun:sqlite, libsql doesn't have a prepared statement object.
102 * Instead, we store the SQL and execute it with parameters each time.
103 *
104 * @param executor - The executor to use (Client or Transaction)
105 * @param sql - The SQL query string
106 */
107const wrapStatement = <R>(
108 executor: { execute: Client["execute"] },
109 sql: string,
110): SQLiteStatement<R> => ({
111 all: <T = R>(...params: unknown[]) =>
112 Effect.tryPromise({
113 try: async () => {
114 const result = await executor.execute({
115 sql,
116 args: params as InArgs,
117 });
118 return resultSetToRows<T>(result);
119 },
120 catch: (e) =>
121 parseError(
122 extractErrorCode(e),
123 `Failed to execute statement.all: ${e}`,
124 e,
125 ),
126 }),
127
128 get: <T = R>(...params: unknown[]) =>
129 Effect.tryPromise({
130 try: async () => {
131 const result = await executor.execute({
132 sql,
133 args: params as InArgs,
134 });
135 const rows = resultSetToRows<T>(result);
136 return rows[0];
137 },
138 catch: (e) =>
139 parseError(
140 extractErrorCode(e),
141 `Failed to execute statement.get: ${e}`,
142 e,
143 ),
144 }),
145
146 run: (...params: unknown[]) =>
147 Effect.tryPromise({
148 try: async () => {
149 await executor.execute({
150 sql,
151 args: params as InArgs,
152 });
153 },
154 catch: (e) =>
155 parseError(
156 extractErrorCode(e),
157 `Failed to execute statement.run: ${e}`,
158 e,
159 ),
160 }),
161});
162
163/**
164 * Extract error code from libsql's LibsqlError.
165 * Prefers extendedCode if available for more specific error types.
166 */
167const extractErrorCode = (e: any): string | undefined => {
168 const libsqlError = e as { code?: string; extendedCode?: string };
169 const code = libsqlError?.extendedCode ?? libsqlError?.code;
170 if (code) {
171 return code;
172 }
173 // Match and parse the tag after "LibsqlError:"
174 const match = e.message.match(/LibsqlError:\s*([A-Z_]+):/);
175 const tag = match ? match[1] : undefined;
176 if (tag) {
177 return tag;
178 }
179 return tag;
180};
181
182/**
183 * Convert a libsql ResultSet to an array of row objects.
184 */
185function resultSetToRows<T>(result: ResultSet): T[] {
186 const columns = result.columns;
187 return result.rows.map((row: Row) => {
188 const obj: Record<string, unknown> = {};
189 for (let i = 0; i < columns.length; i++) {
190 obj[columns[i]] = row[i];
191 }
192 return obj as T;
193 });
194}