323 lines · 10.7 KB · TypeScript
RawDownload
/** * 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<string, unknown> | 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.TaggedErrorClass<UnsupportedNodeSqliteVersionError>()(  "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.TaggedErrorClass<UnsupportedNodeSqliteOperationError>()(  "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<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> {  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<NodeSqlite.StatementSync, boolean>();    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 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({              reason: classifySqliteError(cause, {                message: "Failed to prepare statement",                operation: "prepare",              }),            }),        }),    });     const runStatement = (      statement: NodeSqlite.StatementSync,      params: ReadonlyArray<unknown>,      raw: boolean,    ) =>      Effect.withFiber<ReadonlyArray<any>, 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<any>) : []);        } catch (cause) {          return Effect.fail(            new SqlError({              reason: classifySqliteError(cause, {                message: "Failed to execute statement",                operation: "execute",              }),            }),          );        }      });     const run = (sql: string, params: ReadonlyArray<unknown>, raw = false) =>      Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw));     const runValues = (sql: string, params: ReadonlyArray<unknown>) =>      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<unknown>                >;              }              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),      );     return identity<Connection>({      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 = Effect.try({          try: () => db.prepare(sql),          catch: (cause) =>            new SqlError({              reason: classifySqliteError(cause, {                message: "Failed to prepare statement",                operation: "prepare",              }),            }),        }).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<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>  makeWithDatabase(    options,    () =>      new NodeSqlite.DatabaseSync(options.filename, {        readOnly: options.readonly ?? false,        allowExtension: options.allowExtension ?? false,      }),  ); const makeMemory = (  config: SqliteMemoryClientConfig = {},): Effect.Effect<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>  makeWithDatabase(    {      ...config,      filename: ":memory:",      readonly: false,    },    () => {      const database = new NodeSqlite.DatabaseSync(":memory:", {        allowExtension: config.allowExtension ?? false,      });      return database;    },  ); export const layerConfig = (  config: Config.Wrap<SqliteClientConfig>,): Layer.Layer<Client.SqlClient, Config.ConfigError | SqlError> =>  Layer.effect(Client.SqlClient, Config.unwrap(config).pipe(Effect.flatMap(make))).pipe(    Layer.provide(Reactivity.layer),  ); export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient, SqlError> =>  Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer)); export const layerMemory = (  config: SqliteMemoryClientConfig = {},): Layer.Layer<Client.SqlClient, SqlError> =>  Layer.effect(Client.SqlClient, makeMemory(config)).pipe(Layer.provide(Reactivity.layer));