diff --git a/.changeset/late-baths-end.md b/.changeset/late-baths-end.md new file mode 100644 index 00000000000..e811af90471 --- /dev/null +++ b/.changeset/late-baths-end.md @@ -0,0 +1,9 @@ +--- +"@effect/sql-sqlite-node-sqlite": major +--- + +This new adapter leverages the built-in `DatabaseSync` API available in Node.js 22.x+ and Deno 2.x+. It provides a lightweight, synchronous SQL client without relying on native C++ build tools like `node-gyp`. + +**Features:** +- Seamless compatibility with Deno and modern Node.js environments. +- Zero external native dependencies, simplifying the installation and deployment process. diff --git a/packages/sql-sqlite-node-sqlite/CHANGELOG.md b/packages/sql-sqlite-node-sqlite/CHANGELOG.md new file mode 100644 index 00000000000..b3f0c493fc9 --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/CHANGELOG.md @@ -0,0 +1,12 @@ +# @effect/sql-sqlite-node-sqlite + + +## 0.1.0 + +### Minor Changes + +### Patch Changes + + - effect@3.21.2 + - @effect/sql@0.51.1 + - @effect/platform@0.96.1 diff --git a/packages/sql-sqlite-node-sqlite/LICENSE b/packages/sql-sqlite-node-sqlite/LICENSE new file mode 100644 index 00000000000..7f6fe480f77 --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023-present The Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/sql-sqlite-node-sqlite/README.md b/packages/sql-sqlite-node-sqlite/README.md new file mode 100644 index 00000000000..000650f5554 --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/README.md @@ -0,0 +1,4 @@ +# `@effect/sql-sqlite-node-sqlite` + +An `@effect/sql` implementation using the `node:sqlite` library. + diff --git a/packages/sql-sqlite-node-sqlite/docgen.json b/packages/sql-sqlite-node-sqlite/docgen.json new file mode 100644 index 00000000000..1ec6a9231c5 --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/docgen.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/@effect/docgen/schema.json", + "srcLink": "https://github.com/Effect-TS/effect/tree/main/packages/sql-sqlite-node-sqlite/src/", + "exclude": [ + "src/internal/**/*.ts" + ] +} diff --git a/packages/sql-sqlite-node-sqlite/package.json b/packages/sql-sqlite-node-sqlite/package.json new file mode 100644 index 00000000000..b481cff0121 --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/package.json @@ -0,0 +1,61 @@ +{ + "name": "@effect/sql-sqlite-node-sqlite", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "description": "A SQLite toolkit for Effect", + "homepage": "https://effect.website", + "repository": { + "type": "git", + "url": "https://github.com/Effect-TS/effect.git", + "directory": "packages/sql-sqlite-node-sqlite" + }, + "bugs": { + "url": "https://github.com/Effect-TS/effect/issues" + }, + "tags": [ + "typescript", + "sql", + "database" + ], + "keywords": [ + "typescript", + "sql", + "database" + ], + "publishConfig": { + "access": "public", + "provenance": true, + "directory": "dist", + "linkDirectory": false + }, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./*": "./src/*.ts", + "./internal/*": null + }, + "scripts": { + "codegen": "build-utils prepare-v3", + "build": "pnpm build-esm && pnpm build-annotate && pnpm build-cjs && build-utils pack-v3", + "build-esm": "tsc -b tsconfig.build.json", + "build-cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", + "build-annotate": "babel build/esm --plugins annotate-pure-calls --out-dir build/esm --source-maps", + "check": "tsc -b tsconfig.json", + "test": "vitest", + "coverage": "vitest --coverage" + }, + "devDependencies": { + "@effect/experimental": "workspace:^", + "@effect/platform": "workspace:^", + "@effect/platform-node": "workspace:^", + "@effect/sql": "workspace:^", + "effect": "workspace:^" + }, + "peerDependencies": { + "@effect/experimental": "workspace:^", + "@effect/platform": "workspace:^", + "@effect/sql": "workspace:^", + "effect": "workspace:^" + } +} diff --git a/packages/sql-sqlite-node-sqlite/src/SqliteClient.ts b/packages/sql-sqlite-node-sqlite/src/SqliteClient.ts new file mode 100644 index 00000000000..7686808793f --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/src/SqliteClient.ts @@ -0,0 +1,325 @@ +import * as Reactivity from "@effect/experimental/Reactivity" +import * as Client from "@effect/sql/SqlClient" +import type { Connection } from "@effect/sql/SqlConnection" +import { SqlError } from "@effect/sql/SqlError" +import * as Statement from "@effect/sql/Statement" +import * as Cache from "effect/Cache" +import * as Config from "effect/Config" +import type { ConfigError } from "effect/ConfigError" +import * as Context from "effect/Context" +import * as Duration from "effect/Duration" +import * as Effect from "effect/Effect" +import { identity } from "effect/Function" +import * as Layer from "effect/Layer" +import * as Scope from "effect/Scope" +import { DatabaseSync, type SQLInputValue } from "node:sqlite" + +const ATTR_DB_SYSTEM_NAME = "db.system.name" + +/** + * @category type ids + * @since 1.0.0 + */ +export const TypeId: unique symbol = Symbol.for( + "@effect/sql-sqlite-node-sqlite/SqliteClient" +) +/** + * @category type ids + * @since 1.0.0 + */ +export type TypeId = typeof TypeId + +/** + * @category models + * @since 1.0.0 + */ +export interface SqliteClient extends Client.SqlClient { + readonly [TypeId]: TypeId + readonly config: SqliteClientConfig + readonly export: Effect.Effect + readonly backup: (destination: string) => Effect.Effect + readonly loadExtension: (path: string) => Effect.Effect + + /** Not supported in sqlite */ + readonly updateValues: never +} + +/** + * @category models + * @since 1.0.0 + */ +export interface BackupMetadata { + readonly totalPages: number + readonly remainingPages: number +} + +/** + * @category tags + * @since 1.0.0 + */ +export const SqliteClient = Context.GenericTag( + "@effect/sql-sqlite-node-sqlite/SqliteClient" +) +/** + * @category models + * @since 1.0.0 + */ +export interface SqliteClientConfig { + readonly filename: string + readonly readonly?: boolean | undefined + readonly prepareCacheSize?: number | undefined + readonly prepareCacheTTL?: Duration.DurationInput | undefined + readonly disableWAL?: boolean | undefined + readonly spanAttributes?: Record | undefined + + readonly transformResultNames?: ((str: string) => string) | undefined + readonly transformQueryNames?: ((str: string) => string) | undefined +} + +interface SqliteConnection extends Connection { + readonly export: Effect.Effect + readonly backup: (destination: string) => Effect.Effect + readonly loadExtension: (path: string) => Effect.Effect +} + +/** + * @category constructor + * @since 1.0.0 + */ +export const make = ( + options: SqliteClientConfig +): 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 = new DatabaseSync(options.filename, { + readOnly: options.readonly ?? false + }) + yield* Scope.addFinalizer(scope, Effect.sync(() => db.close())) + + if (options.disableWAL !== true) { + db.exec("PRAGMA journal_mode = WAL") + } + + 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 p = (params: ReadonlyArray) => params as unknown as Array + + const runStatement = ( + statement: ReturnType, + params: ReadonlyArray, + raw: boolean + ) => + Effect.withFiberRuntime, SqlError>((fiber) => { + if (Context.get(fiber.currentContext, Client.SafeIntegers)) { + statement.setReadBigInts(true) + } + try { + // Uses StatementSync.columns() (available since Node.js 22.12.0) + if (raw) { + if (statement.columns().length > 0) { + return Effect.succeed( + statement.all(...p(params)) as ReadonlyArray + ) + } + return Effect.succeed( + statement.run(...p(params)) as unknown as ReadonlyArray + ) + } + // For normal execute, always use all() — it returns [] for + // non-readers, so no reader detection is needed. + return Effect.succeed( + statement.all(...p(params)) 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( + prepareCache.get(sql), + (s) => runStatement(s, params, raw) + ) + + const runValues = ( + sql: string, + params: ReadonlyArray + ) => + Effect.flatMap( + prepareCache.get(sql), + (statement) => { + // Use setReturnArrays() when available (Node.js >= 24.0.0) + // for native array output, otherwise fall back to Object.values() + if (typeof (statement as any).setReturnArrays === "function") { + return Effect.acquireUseRelease( + Effect.succeed(statement), + (stmt) => + Effect.try({ + try: () => { + ;(stmt as any).setReturnArrays(true) + return stmt.all(...p(params)) as unknown as ReadonlyArray< + ReadonlyArray + > + }, + catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }) + }), + (stmt) => Effect.sync(() => (stmt as any).setReturnArrays(false)) + ) + } + // Fallback: all() returns [] for non-readers, so no reader + // detection is needed — just convert row objects to value arrays. + return Effect.try({ + try: () => { + const rows = statement.all(...p(params)) as Array< + Record + > + return rows.map((row) => Object.values(row)) as ReadonlyArray< + ReadonlyArray + > + }, + catch: (cause) => new SqlError({ cause, message: "Failed to execute statement" }) + }) + } + ) + + return identity({ + execute(sql, params, transformRows) { + return transformRows + ? Effect.map(run(sql, params), transformRows) + : run(sql, params) + }, + executeRaw(sql, params) { + return run(sql, params, true) + }, + executeValues(sql, params) { + return runValues(sql, params) + }, + executeUnprepared(sql, params, transformRows) { + const effect = runStatement(db.prepare(sql), params ?? [], false) + return transformRows ? Effect.map(effect, transformRows) : effect + }, + executeStream(_sql, _params) { + return Effect.dieMessage("executeStream not implemented") + }, + export: Effect.dieMessage("export not supported in node:sqlite"), + backup(destination) { + return Effect.tryPromise({ + try: async () => { + // backup() is a module-level function available in Node.js >= 22.12.0 + const mod = await import("node:sqlite") + if (typeof mod.backup !== "function") { + throw new Error( + "backup not supported (requires Node.js >= 22.12.0)" + ) + } + await mod.backup(db, destination) + return { totalPages: 0, remainingPages: 0 } as BackupMetadata + }, + catch: (cause) => new SqlError({ cause, message: "Failed to backup database" }) + }) + }, + loadExtension(path) { + return Effect.tap( + Effect.try({ + try: () => db.loadExtension(path), + catch: (cause) => new SqlError({ cause, message: "Failed to load extension" }) + }), + () => prepareCache.invalidateAll + ) + } + }) + }) + + const semaphore = yield* Effect.makeSemaphore(1) + const connection = yield* makeConnection + + const acquirer = semaphore.withPermits(1)(Effect.succeed(connection)) + const transactionAcquirer = Effect.uninterruptibleMask((restore) => + Effect.as( + Effect.zipRight( + restore(semaphore.take(1)), + Effect.tap( + Effect.scope, + (scope) => Scope.addFinalizer(scope, semaphore.release(1)) + ) + ), + connection + ) + ) + + return Object.assign( + (yield* Client.make({ + acquirer, + compiler, + transactionAcquirer, + spanAttributes: [ + ...(options.spanAttributes ? + Object.entries(options.spanAttributes) : + []), + [ATTR_DB_SYSTEM_NAME, "sqlite"] + ], + transformRows + })) as SqliteClient, + { + [TypeId]: TypeId as TypeId, + config: options, + export: Effect.flatMap(acquirer, (_) => _.export), + backup: (destination: string) => Effect.flatMap(acquirer, (_) => _.backup(destination)), + loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)) + } + ) + }) + +/** + * @category layers + * @since 1.0.0 + */ +export const layerConfig = ( + config: Config.Config.Wrap +): Layer.Layer => + Layer.scopedContext( + Config.unwrap(config).pipe( + Effect.flatMap(make), + Effect.map((client) => + Context.make(SqliteClient, client).pipe( + Context.add(Client.SqlClient, client) + ) + ) + ) + ).pipe(Layer.provide(Reactivity.layer)) + +/** + * @category layers + * @since 1.0.0 + */ +export const layer = ( + config: SqliteClientConfig +): Layer.Layer => + Layer.scopedContext( + Effect.map(make(config), (client) => + Context.make(SqliteClient, client).pipe( + Context.add(Client.SqlClient, client) + )) + ).pipe(Layer.provide(Reactivity.layer)) diff --git a/packages/sql-sqlite-node-sqlite/src/SqliteMigrator.ts b/packages/sql-sqlite-node-sqlite/src/SqliteMigrator.ts new file mode 100644 index 00000000000..ec299b7e8bf --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/src/SqliteMigrator.ts @@ -0,0 +1,90 @@ +/** + * @since 1.0.0 + */ +import * as Command from "@effect/platform/Command" +import type { CommandExecutor } from "@effect/platform/CommandExecutor" +import { FileSystem } from "@effect/platform/FileSystem" +import { Path } from "@effect/platform/Path" +import * as Migrator from "@effect/sql/Migrator" +import type * as Client from "@effect/sql/SqlClient" +import type { SqlError } from "@effect/sql/SqlError" +import * as Effect from "effect/Effect" +import { pipe } from "effect/Function" +import * as Layer from "effect/Layer" +import { SqliteClient } from "./SqliteClient.js" + +/** + * @since 1.0.0 + */ +export * from "@effect/sql/Migrator" + +/** + * @since 1.0.0 + */ +export * from "@effect/sql/Migrator/FileSystem" + +/** + * @category constructor + * @since 1.0.0 + */ +export const run: ( + options: Migrator.MigratorOptions +) => Effect.Effect< + ReadonlyArray, + Migrator.MigrationError | SqlError, + FileSystem | Path | SqliteClient | Client.SqlClient | CommandExecutor | R2 +> = Migrator.make({ + dumpSchema(path, table) { + const dump = (args: Array) => + Effect.gen(function*() { + const sql = yield* SqliteClient + const dump = yield* pipe( + Command.make("sqlite3", sql.config.filename, ...args), + Command.string + ) + return dump.replace(/^create table sqlite_sequence\(.*$/im, "") + .replace(/\n{2,}/gm, "\n\n") + .trim() + }).pipe( + Effect.mapError((error) => new Migrator.MigrationError({ reason: "failed", message: error.message })) + ) + + const dumpSchema = dump([".schema"]) + + const dumpMigrations = dump([ + "--cmd", + `.mode insert ${table}`, + `select * from ${table}` + ]) + + const dumpAll = Effect.map( + Effect.all([dumpSchema, dumpMigrations], { concurrency: 2 }), + ([schema, migrations]) => schema + "\n\n" + migrations + ) + + const dumpFile = (file: string) => + Effect.gen(function*() { + const fs = yield* FileSystem + const path = yield* Path + const dump = yield* dumpAll + yield* fs.makeDirectory(path.dirname(file), { recursive: true }) + yield* fs.writeFileString(file, dump) + }).pipe( + Effect.mapError((error) => new Migrator.MigrationError({ reason: "failed", message: error.message })) + ) + + return dumpFile(path) + } +}) + +/** + * @category constructor + * @since 1.0.0 + */ +export const layer = ( + options: Migrator.MigratorOptions +): Layer.Layer< + never, + SqlError | Migrator.MigrationError, + SqliteClient | Client.SqlClient | CommandExecutor | FileSystem | Path | R +> => Layer.effectDiscard(run(options)) diff --git a/packages/sql-sqlite-node-sqlite/src/index.ts b/packages/sql-sqlite-node-sqlite/src/index.ts new file mode 100644 index 00000000000..8f5fef60769 --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/src/index.ts @@ -0,0 +1,10 @@ +/** + * @category type ids + * @since 1.0.0 + */ +export * as SqliteClient from "./SqliteClient.js" + +/** + * @since 1.0.0 + */ +export * as SqliteMigrator from "./SqliteMigrator.js" diff --git a/packages/sql-sqlite-node-sqlite/test/Client.test.ts b/packages/sql-sqlite-node-sqlite/test/Client.test.ts new file mode 100644 index 00000000000..65504ad181a --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/test/Client.test.ts @@ -0,0 +1,76 @@ +import { Reactivity } from "@effect/experimental" +import { FileSystem } from "@effect/platform" +import { NodeFileSystem } from "@effect/platform-node" +import { SqliteClient } from "@effect/sql-sqlite-node-sqlite" +import { assert, describe, it } from "@effect/vitest" +import { Effect } from "effect" + +const makeClient = Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const dir = yield* fs.makeTempDirectoryScoped() + return yield* SqliteClient.make({ + filename: dir + "/test.db" + }) +}).pipe(Effect.provide([NodeFileSystem.layer, Reactivity.layer])) + +describe("NodeSqlite Client", () => { + it.scoped("should work", () => + Effect.gen(function*() { + const sql = yield* makeClient + let response + response = yield* sql`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)` + assert.deepStrictEqual(response, []) + response = yield* sql`INSERT INTO test (name) VALUES ('hello')` + assert.deepStrictEqual(response, []) + response = yield* sql`SELECT * FROM test` + assert.deepStrictEqual(response, [{ id: 1, name: "hello" }]) + response = yield* sql`INSERT INTO test (name) VALUES ('world')`.pipe(sql.withTransaction) + assert.deepStrictEqual(response, []) + response = yield* sql`SELECT * FROM test` + assert.deepStrictEqual(response, [ + { id: 1, name: "hello" }, + { id: 2, name: "world" } + ]) + })) + + it.scoped("should work with raw", () => + Effect.gen(function*() { + const sql = yield* makeClient + let response + response = yield* sql`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`.raw + assert.deepStrictEqual(response, { changes: 0, lastInsertRowid: 0 }) + response = yield* sql`INSERT INTO test (name) VALUES ('hello')`.raw + assert.deepStrictEqual(response, { changes: 1, lastInsertRowid: 1 }) + response = yield* sql`SELECT * FROM test`.raw + assert.deepStrictEqual(response, [{ id: 1, name: "hello" }]) + response = yield* sql`INSERT INTO test (name) VALUES ('world')`.raw.pipe(sql.withTransaction) + assert.deepStrictEqual(response, { changes: 1, lastInsertRowid: 2 }) + response = yield* sql`SELECT * FROM test` + assert.deepStrictEqual(response, [ + { id: 1, name: "hello" }, + { id: 2, name: "world" } + ]) + })) + + it.scoped("withTransaction", () => + Effect.gen(function*() { + const sql = yield* makeClient + yield* sql`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)` + yield* sql.withTransaction(sql`INSERT INTO test (name) VALUES ('hello')`) + const rows = yield* sql`SELECT * FROM test` + assert.deepStrictEqual(rows, [{ id: 1, name: "hello" }]) + })) + + it.scoped("withTransaction rollback", () => + Effect.gen(function*() { + const sql = yield* makeClient + yield* sql`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)` + yield* sql`INSERT INTO test (name) VALUES ('hello')`.pipe( + Effect.andThen(Effect.fail("boom")), + sql.withTransaction, + Effect.ignore + ) + const rows = yield* sql`SELECT * FROM test` + assert.deepStrictEqual(rows, []) + })) +}) diff --git a/packages/sql-sqlite-node-sqlite/test/Resolver.test.ts b/packages/sql-sqlite-node-sqlite/test/Resolver.test.ts new file mode 100644 index 00000000000..efd571474ac --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/test/Resolver.test.ts @@ -0,0 +1,177 @@ +import { Reactivity } from "@effect/experimental" +import { FileSystem } from "@effect/platform" +import { NodeFileSystem } from "@effect/platform-node" +import { SqlError, SqlResolver } from "@effect/sql" +import { SqliteClient } from "@effect/sql-sqlite-node-sqlite" +import { assert, describe, it } from "@effect/vitest" +import { Array, Effect, Option } from "effect" +import * as Schema from "effect/Schema" + +const makeClient = Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const dir = yield* fs.makeTempDirectoryScoped() + return yield* SqliteClient.make({ + filename: dir + "/test.db" + }) +}).pipe(Effect.provide([NodeFileSystem.layer, Reactivity.layer])) + +const seededClient = Effect.gen(function*() { + const sql = yield* makeClient + yield* sql`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)` + yield* Effect.forEach(Array.range(1, 100), (id) => sql`INSERT INTO test ${sql.insert({ id, name: `name${id}` })}`) + return sql +}) + +describe("NodeSqlite Resolver", () => { + describe("ordered", () => { + it.scoped("insert", () => + Effect.gen(function*() { + const batches: Array> = [] + const sql = yield* seededClient + const Insert = yield* SqlResolver.ordered("Insert", { + Request: Schema.String, + Result: Schema.Struct({ id: Schema.Number, name: Schema.String }), + execute: (names) => { + batches.push(names) + return sql`INSERT INTO test ${sql.insert(names.map((name) => ({ name })))} RETURNING *` + } + }) + assert.deepStrictEqual( + yield* Effect.all({ + one: Insert.execute("one"), + two: Insert.execute("two") + }, { batching: true }), + { + one: { id: 101, name: "one" }, + two: { id: 102, name: "two" } + } + ) + assert.deepStrictEqual(batches, [["one", "two"]]) + })) + + it.scoped("result length mismatch", () => + Effect.gen(function*() { + const batches: Array> = [] + const sql = yield* seededClient + const Select = yield* SqlResolver.ordered("Select", { + Request: Schema.Number, + Result: Schema.Struct({ id: Schema.Number, name: Schema.String }), + execute: (ids) => { + batches.push(ids) + return sql`SELECT * FROM test WHERE id IN ${sql.in(ids)}` + } + }) + const error = yield* Effect.all([ + Select.execute(1), + Select.execute(2), + Select.execute(3), + Select.execute(101) + ], { batching: true }) + .pipe(Effect.flip) + assert(error instanceof SqlError.ResultLengthMismatch) + assert.strictEqual(error.actual, 3) + assert.strictEqual(error.expected, 4) + assert.deepStrictEqual(batches, [[1, 2, 3, 101]]) + })) + }) + + describe("grouped", () => { + it.scoped("find by name", () => + Effect.gen(function*() { + const sql = yield* seededClient + const FindByName = yield* SqlResolver.grouped("FindByName", { + Request: Schema.String, + RequestGroupKey: (name) => name, + Result: Schema.Struct({ id: Schema.Number, name: Schema.String }), + ResultGroupKey: (result) => result.name, + execute: (names) => sql`SELECT * FROM test WHERE name IN ${sql.in(names)}` + }) + yield* sql`INSERT INTO test ${sql.insert({ name: "name1" })}` + assert.deepStrictEqual( + yield* Effect.all({ + one: FindByName.execute("name1"), + two: FindByName.execute("name2"), + three: FindByName.execute("name0") + }, { batching: true }), + { + one: [{ id: 1, name: "name1" }, { id: 101, name: "name1" }], + two: [{ id: 2, name: "name2" }], + three: [] + } + ) + })) + + it.scoped("using raw rows", () => + Effect.gen(function*() { + const sql = yield* seededClient + const FindByName = yield* SqlResolver.grouped("FindByName", { + Request: Schema.String, + RequestGroupKey: (name) => name, + Result: Schema.Struct({ id: Schema.Number, name: Schema.String }), + ResultGroupKey: (_, result: any) => result.name, + execute: (names) => sql`SELECT * FROM test WHERE name IN ${sql.in(names)}` + }) + yield* sql`INSERT INTO test ${sql.insert({ name: "name1" })}` + assert.deepStrictEqual( + yield* Effect.all({ + one: FindByName.execute("name1"), + two: FindByName.execute("name2"), + three: FindByName.execute("name0") + }, { batching: true }), + { + one: [{ id: 1, name: "name1" }, { id: 101, name: "name1" }], + two: [{ id: 2, name: "name2" }], + three: [] + } + ) + })) + }) + + describe("findById", () => { + it.scoped("find by id", () => + Effect.gen(function*() { + const sql = yield* seededClient + const FindById = yield* SqlResolver.findById("FindById", { + Id: Schema.Number, + Result: Schema.Struct({ id: Schema.Number, name: Schema.String }), + ResultId: (result) => result.id, + execute: (ids) => sql`SELECT * FROM test WHERE id IN ${sql.in(ids)}` + }) + assert.deepStrictEqual( + yield* Effect.all({ + one: FindById.execute(1), + two: FindById.execute(2), + three: FindById.execute(101) + }, { batching: true }), + { + one: Option.some({ id: 1, name: "name1" }), + two: Option.some({ id: 2, name: "name2" }), + three: Option.none() + } + ) + })) + + it.scoped("using raw rows", () => + Effect.gen(function*() { + const sql = yield* seededClient + const FindById = yield* SqlResolver.findById("FindById", { + Id: Schema.Number, + Result: Schema.Struct({ id: Schema.Number, name: Schema.String }), + ResultId: (_, result: any) => result.id, + execute: (ids) => sql`SELECT * FROM test WHERE id IN ${sql.in(ids)}` + }) + assert.deepStrictEqual( + yield* Effect.all({ + one: FindById.execute(1), + two: FindById.execute(2), + three: FindById.execute(101) + }, { batching: true }), + { + one: Option.some({ id: 1, name: "name1" }), + two: Option.some({ id: 2, name: "name2" }), + three: Option.none() + } + ) + })) + }) +}) diff --git a/packages/sql-sqlite-node-sqlite/test/SqlPersistedQueue.test.ts b/packages/sql-sqlite-node-sqlite/test/SqlPersistedQueue.test.ts new file mode 100644 index 00000000000..81a03691801 --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/test/SqlPersistedQueue.test.ts @@ -0,0 +1,17 @@ +import { Reactivity } from "@effect/experimental" +import { FileSystem } from "@effect/platform" +import { NodeFileSystem } from "@effect/platform-node" +import { SqliteClient } from "@effect/sql-sqlite-node-sqlite" +import { Effect } from "effect" +import * as Layer from "effect/Layer" +import * as SqlPersistedQueueTest from "../../sql/test/SqlPersistedQueueTest.js" + +const SqliteClientLayer = Layer.unwrapScoped(Effect.gen(function*() { + const fs = yield* FileSystem.FileSystem + const dir = yield* fs.makeTempDirectoryScoped() + return SqliteClient.layer({ + filename: dir + "/test.db" + }) +})).pipe(Layer.provide([NodeFileSystem.layer, Reactivity.layer])) + +SqlPersistedQueueTest.suite(SqliteClientLayer) diff --git a/packages/sql-sqlite-node-sqlite/tsconfig.build.json b/packages/sql-sqlite-node-sqlite/tsconfig.build.json new file mode 100644 index 00000000000..8a5c6b4d6ee --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.src.json", + "references": [ + { "path": "../effect/tsconfig.build.json" }, + { "path": "../sql/tsconfig.build.json" } + ], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/build.tsbuildinfo", + "outDir": "build/esm", + "declarationDir": "build/dts", + "stripInternal": true + } +} diff --git a/packages/sql-sqlite-node-sqlite/tsconfig.json b/packages/sql-sqlite-node-sqlite/tsconfig.json new file mode 100644 index 00000000000..2c291d2192d --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "include": [], + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "tsconfig.test.json" } + ] +} diff --git a/packages/sql-sqlite-node-sqlite/tsconfig.src.json b/packages/sql-sqlite-node-sqlite/tsconfig.src.json new file mode 100644 index 00000000000..13590c3e70a --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/tsconfig.src.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"], + "references": [ + { "path": "../effect/tsconfig.src.json" }, + { "path": "../sql/tsconfig.src.json" } + ], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", + "rootDir": "src", + "outDir": "build/src", + "types": ["node"] + } +} diff --git a/packages/sql-sqlite-node-sqlite/tsconfig.test.json b/packages/sql-sqlite-node-sqlite/tsconfig.test.json new file mode 100644 index 00000000000..18fd97a9819 --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["test"], + "references": [ + { "path": "tsconfig.src.json" }, + { "path": "../platform-node/tsconfig.src.json" }, + { "path": "../sql/tsconfig.test.json" }, + { "path": "../vitest/tsconfig.src.json" } + ], + "compilerOptions": { + "tsBuildInfoFile": ".tsbuildinfo/test.tsbuildinfo", + "rootDir": "test", + "noEmit": true + } +} diff --git a/packages/sql-sqlite-node-sqlite/vitest.config.ts b/packages/sql-sqlite-node-sqlite/vitest.config.ts new file mode 100644 index 00000000000..83795c892cb --- /dev/null +++ b/packages/sql-sqlite-node-sqlite/vitest.config.ts @@ -0,0 +1,10 @@ +import { mergeConfig, type ViteUserConfig } from "vitest/config" +import shared from "../../vitest.shared.js" + +const config: ViteUserConfig = { + test: { + exclude: ["**/SqlPersistedQueue.test.ts"] + } +} + +export default mergeConfig(shared, config)