385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
/**
|
|
* libSQL adapter for Effect SQL, backed by `@libsql/client`.
|
|
*
|
|
* This module provides a {@link LibsqlClient} and the generic SQL client
|
|
* service for `@libsql/client`. It uses Effect SQL's SQLite compiler, supports
|
|
* managed SDK clients or caller-owned live clients, classifies libSQL and
|
|
* SQLite failures as `SqlError`s, and provides transaction support with
|
|
* savepoints. Streaming queries are not implemented by this driver.
|
|
*
|
|
* @since 4.0.0
|
|
*/
|
|
import * as Libsql from "@libsql/client"
|
|
import * as Config from "effect/Config"
|
|
import * as Context from "effect/Context"
|
|
import * as Effect from "effect/Effect"
|
|
import * as Layer from "effect/Layer"
|
|
import * as Option from "effect/Option"
|
|
import * as Redacted from "effect/Redacted"
|
|
import * as Scope from "effect/Scope"
|
|
import * as Semaphore from "effect/Semaphore"
|
|
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 { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
|
|
import * as Statement from "effect/unstable/sql/Statement"
|
|
|
|
const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
|
|
|
const classifyError = (cause: unknown, message: string, operation: string) =>
|
|
classifySqliteError(cause, { message, operation })
|
|
|
|
/**
|
|
* Runtime type identifier used to mark `LibsqlClient` values.
|
|
*
|
|
* @category type IDs
|
|
* @since 4.0.0
|
|
*/
|
|
export const TypeId: TypeId = "~@effect/sql-libsql/LibsqlClient"
|
|
|
|
/**
|
|
* Type-level identifier used to mark `LibsqlClient` values.
|
|
*
|
|
* @category type IDs
|
|
* @since 4.0.0
|
|
*/
|
|
export type TypeId = "~@effect/sql-libsql/LibsqlClient"
|
|
|
|
/**
|
|
* libSQL-backed SQL client service, extending `SqlClient` with its runtime type marker and client configuration.
|
|
*
|
|
* @category models
|
|
* @since 4.0.0
|
|
*/
|
|
export interface LibsqlClient extends Client.SqlClient {
|
|
readonly [TypeId]: TypeId
|
|
readonly config: LibsqlClientConfig
|
|
}
|
|
|
|
/**
|
|
* Service tag for the libSQL client service.
|
|
*
|
|
* **When to use**
|
|
*
|
|
* Use to access or provide a libSQL client through the Effect context.
|
|
*
|
|
* @category services
|
|
* @since 4.0.0
|
|
*/
|
|
export const LibsqlClient = Context.Service<LibsqlClient>("@effect/sql-libsql/LibsqlClient")
|
|
|
|
const LibsqlTransaction = Context.Service<readonly [LibsqlConnection, counter: number]>(
|
|
"@effect/sql-libsql/LibsqlClient/LibsqlTransaction"
|
|
)
|
|
|
|
/**
|
|
* Configuration for a libSQL client, either by supplying connection options or an existing live libSQL client.
|
|
*
|
|
* @category models
|
|
* @since 4.0.0
|
|
*/
|
|
export type LibsqlClientConfig = LibsqlClientConfig.Full | LibsqlClientConfig.Live
|
|
|
|
/**
|
|
* Namespace containing the configuration variants for `LibsqlClient`.
|
|
*
|
|
* @since 4.0.0
|
|
*/
|
|
export declare namespace LibsqlClientConfig {
|
|
/**
|
|
* Shared libSQL client options for span attributes and query/result name transformations.
|
|
*
|
|
* @category models
|
|
* @since 4.0.0
|
|
*/
|
|
export interface Base {
|
|
readonly spanAttributes?: Record<string, unknown> | undefined
|
|
readonly transformResultNames?: ((str: string) => string) | undefined
|
|
readonly transformQueryNames?: ((str: string) => string) | undefined
|
|
}
|
|
|
|
/**
|
|
* Connection-based libSQL configuration used to create a managed client, including URL, credentials, sync, integer mode, TLS, and concurrency options.
|
|
*
|
|
* @category models
|
|
* @since 4.0.0
|
|
*/
|
|
export interface Full extends Base {
|
|
/**
|
|
* The database URL.
|
|
*
|
|
* **Details**
|
|
*
|
|
* The client supports `libsql:`, `http:`/`https:`, `ws:`/`wss:` and `file:` URL. For more infomation,
|
|
* please refer to the project README:
|
|
*
|
|
* https://github.com/libsql/libsql-client-ts#supported-urls
|
|
*/
|
|
readonly url: string | URL
|
|
/** Authentication token for the database. */
|
|
readonly authToken?: Redacted.Redacted | undefined
|
|
/** Encryption key for the database. */
|
|
readonly encryptionKey?: Redacted.Redacted | undefined
|
|
/** URL of a remote server to synchronize database with. */
|
|
readonly syncUrl?: string | URL | undefined
|
|
/** Sync interval in seconds. */
|
|
readonly syncInterval?: number | undefined
|
|
/**
|
|
* Enables or disables TLS for `libsql:` URLs.
|
|
*
|
|
* **Details**
|
|
*
|
|
* By default, `libsql:` URLs use TLS. You can set this option to `false` to disable TLS.
|
|
*/
|
|
readonly tls?: boolean | undefined
|
|
/**
|
|
* How to convert SQLite integers to JavaScript values.
|
|
*
|
|
* **Details**
|
|
*
|
|
* - `"number"` (default): returns SQLite integers as JavaScript `number`-s (double precision floats).
|
|
* `number` cannot precisely represent integers larger than 2^53-1 in absolute value, so attempting to read
|
|
* larger integers will throw a `RangeError`.
|
|
* - `"bigint"`: returns SQLite integers as JavaScript `bigint`-s (arbitrary precision integers). Bigints can
|
|
* precisely represent all SQLite integers.
|
|
* - `"string"`: returns SQLite integers as strings.
|
|
*/
|
|
readonly intMode?: "number" | "bigint" | "string" | undefined
|
|
/**
|
|
* Concurrency limit.
|
|
*
|
|
* **Details**
|
|
*
|
|
* By default, the client performs up to 20 concurrent requests. You can set this option to a higher
|
|
* number to increase the concurrency limit or set it to 0 to disable concurrency limits completely.
|
|
*/
|
|
readonly concurrency?: number | undefined
|
|
}
|
|
|
|
/**
|
|
* Configuration that uses an existing libSQL client. The supplied `liveClient` is caller-owned and is not closed by the Effect client.
|
|
*
|
|
* @category models
|
|
* @since 4.0.0
|
|
*/
|
|
export interface Live extends Base {
|
|
readonly liveClient: Libsql.Client
|
|
}
|
|
}
|
|
|
|
interface LibsqlConnection extends Connection {
|
|
readonly beginTransaction: Effect.Effect<LibsqlConnection, SqlError>
|
|
readonly commit: Effect.Effect<void, SqlError>
|
|
readonly rollback: Effect.Effect<void, SqlError>
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @category constructors
|
|
* @since 4.0.0
|
|
*/
|
|
export const make = (
|
|
options: LibsqlClientConfig
|
|
): Effect.Effect<LibsqlClient, never, Scope.Scope | Reactivity.Reactivity> =>
|
|
Effect.gen(function*() {
|
|
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
|
|
const transformRows = options.transformResultNames ?
|
|
Statement.defaultTransforms(
|
|
options.transformResultNames
|
|
).array :
|
|
undefined
|
|
|
|
const spanAttributes: Array<[string, unknown]> = [
|
|
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
|
|
[ATTR_DB_SYSTEM_NAME, "sqlite"]
|
|
]
|
|
|
|
class LibsqlConnectionImpl implements LibsqlConnection {
|
|
readonly sdk: Libsql.Client | Libsql.Transaction
|
|
constructor(sdk: Libsql.Client | Libsql.Transaction) {
|
|
this.sdk = sdk
|
|
}
|
|
|
|
run(
|
|
sql: string,
|
|
params: ReadonlyArray<unknown> = []
|
|
) {
|
|
return Effect.map(
|
|
Effect.tryPromise({
|
|
try: () => this.sdk.execute({ sql, args: params as Array<any> }),
|
|
catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to execute statement", "execute") })
|
|
}),
|
|
(results) => results.rows
|
|
)
|
|
}
|
|
|
|
runRaw(
|
|
sql: string,
|
|
params: ReadonlyArray<unknown> = []
|
|
) {
|
|
return Effect.tryPromise({
|
|
try: () => this.sdk.execute({ sql, args: params as Array<any> }),
|
|
catch: (cause) => new SqlError({ reason: classifyError(cause, "Failed to execute statement", "execute") })
|
|
})
|
|
}
|
|
|
|
execute(
|
|
sql: string,
|
|
params: ReadonlyArray<unknown>,
|
|
transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
|
|
) {
|
|
return transformRows
|
|
? Effect.map(this.run(sql, params), transformRows)
|
|
: this.run(sql, params)
|
|
}
|
|
executeRaw(sql: string, params: ReadonlyArray<unknown>) {
|
|
return this.runRaw(sql, params)
|
|
}
|
|
executeValues(sql: string, params: ReadonlyArray<unknown>) {
|
|
return Effect.map(this.run(sql, params), (rows) => rows.map((row) => Array.from(row) as Array<any>))
|
|
}
|
|
executeValuesUnprepared(sql: string, params: ReadonlyArray<unknown>) {
|
|
return this.executeValues(sql, params)
|
|
}
|
|
executeUnprepared(
|
|
sql: string,
|
|
params: ReadonlyArray<unknown>,
|
|
transformRows: (<A extends object>(row: ReadonlyArray<A>) => ReadonlyArray<A>) | undefined
|
|
) {
|
|
return this.execute(sql, params, transformRows)
|
|
}
|
|
executeStream() {
|
|
return Stream.die("executeStream not implemented")
|
|
}
|
|
get beginTransaction() {
|
|
return Effect.map(
|
|
Effect.tryPromise({
|
|
try: () => (this.sdk as Libsql.Client).transaction("write"),
|
|
catch: (cause) =>
|
|
new SqlError({ reason: classifyError(cause, "Failed to begin transaction", "beginTransaction") })
|
|
}),
|
|
(tx) => new LibsqlConnectionImpl(tx)
|
|
)
|
|
}
|
|
get commit() {
|
|
return Effect.tryPromise({
|
|
try: () => (this.sdk as Libsql.Transaction).commit(),
|
|
catch: (cause) =>
|
|
new SqlError({ reason: classifyError(cause, "Failed to commit transaction", "commitTransaction") })
|
|
})
|
|
}
|
|
get rollback() {
|
|
return Effect.tryPromise({
|
|
try: () => (this.sdk as Libsql.Transaction).rollback(),
|
|
catch: (cause) =>
|
|
new SqlError({ reason: classifyError(cause, "Failed to rollback transaction", "rollbackTransaction") })
|
|
})
|
|
}
|
|
}
|
|
|
|
const connection = "liveClient" in options
|
|
? new LibsqlConnectionImpl(options.liveClient)
|
|
: yield* Effect.map(
|
|
Effect.acquireRelease(
|
|
Effect.sync(() =>
|
|
Libsql.createClient(
|
|
{
|
|
...options,
|
|
authToken: Redacted.isRedacted(options.authToken)
|
|
? Redacted.value(options.authToken)
|
|
: options.authToken,
|
|
encryptionKey: Redacted.isRedacted(options.encryptionKey)
|
|
? Redacted.value(options.encryptionKey)
|
|
: options.encryptionKey,
|
|
url: options.url.toString(),
|
|
syncUrl: options.syncUrl?.toString()
|
|
} as Libsql.Config
|
|
)
|
|
),
|
|
(sdk) => Effect.sync(() => sdk.close())
|
|
),
|
|
(sdk) => new LibsqlConnectionImpl(sdk)
|
|
)
|
|
const semaphore = yield* Semaphore.make(1)
|
|
|
|
const withTransaction = Client.makeWithTransaction({
|
|
transactionService: LibsqlTransaction,
|
|
spanAttributes,
|
|
acquireConnection: Effect.uninterruptibleMask(Effect.fnUntraced(function*(restore) {
|
|
const scope = Scope.makeUnsafe()
|
|
yield* restore(semaphore.take(1))
|
|
yield* Scope.addFinalizer(scope, semaphore.release(1))
|
|
const conn = yield* connection.beginTransaction
|
|
return [scope, conn] as const
|
|
})),
|
|
begin: () => Effect.void, // already begun in acquireConnection
|
|
savepoint: (conn, id) => conn.executeRaw(`SAVEPOINT effect_sql_${id};`, []),
|
|
commit: (conn) => conn.commit,
|
|
rollback: (conn) => conn.rollback,
|
|
rollbackSavepoint: (conn, id) => conn.executeRaw(`ROLLBACK TO SAVEPOINT effect_sql_${id};`, [])
|
|
})
|
|
|
|
const acquirer = Effect.flatMap(
|
|
Effect.serviceOption(LibsqlTransaction),
|
|
Option.match({
|
|
onNone: () => semaphore.withPermits(1)(Effect.succeed(connection as LibsqlConnection)),
|
|
onSome: ([conn]) => Effect.succeed(conn)
|
|
})
|
|
)
|
|
|
|
return Object.assign(
|
|
yield* Client.make({
|
|
acquirer,
|
|
compiler,
|
|
spanAttributes,
|
|
transformRows
|
|
}),
|
|
{
|
|
[TypeId]: TypeId as TypeId,
|
|
config: options,
|
|
withTransaction,
|
|
sdk: connection.sdk
|
|
}
|
|
)
|
|
})
|
|
|
|
/**
|
|
* Creates a layer from a `Config`-wrapped libSQL client configuration, providing both `LibsqlClient` and `SqlClient`.
|
|
*
|
|
* @category layers
|
|
* @since 4.0.0
|
|
*/
|
|
export const layerConfig: (
|
|
config: Config.Wrap<LibsqlClientConfig>
|
|
) => Layer.Layer<LibsqlClient | Client.SqlClient, Config.ConfigError> = (
|
|
config: Config.Wrap<LibsqlClientConfig>
|
|
): Layer.Layer<LibsqlClient | Client.SqlClient, Config.ConfigError> =>
|
|
Layer.effectContext(
|
|
Config.unwrap(config).pipe(
|
|
Effect.flatMap(make),
|
|
Effect.map((client) =>
|
|
Context.make(LibsqlClient, client).pipe(
|
|
Context.add(Client.SqlClient, client)
|
|
)
|
|
)
|
|
)
|
|
).pipe(Layer.provide(Reactivity.layer))
|
|
|
|
/**
|
|
* Creates a layer from a concrete libSQL client configuration, providing both `LibsqlClient` and `SqlClient`.
|
|
*
|
|
* @category layers
|
|
* @since 4.0.0
|
|
*/
|
|
export const layer = (
|
|
config: LibsqlClientConfig
|
|
): Layer.Layer<LibsqlClient | Client.SqlClient> =>
|
|
Layer.effectContext(
|
|
Effect.map(make(config), (client) =>
|
|
Context.make(LibsqlClient, client).pipe(
|
|
Context.add(Client.SqlClient, client)
|
|
))
|
|
).pipe(Layer.provide(Reactivity.layer))
|