diff --git a/apps/server/scripts/migrate-dev-db.test.ts b/apps/server/scripts/migrate-dev-db.test.ts index ddc5b7d57f86f0c0643321d20398951486069865..88308d370a6b611fb939471499f437e24365658d 100644 --- a/apps/server/scripts/migrate-dev-db.test.ts +++ b/apps/server/scripts/migrate-dev-db.test.ts @@ -6,7 +6,7 @@ import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../src/persistence/Migrations.ts"; -import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runMigrateDevDb } from "./migrate-dev-db.ts"; const withDatabase = ( diff --git a/apps/server/scripts/migrate-dev-db.ts b/apps/server/scripts/migrate-dev-db.ts index 0958f2149f45f36ac907014ec34a8c1a4b8ecbee..5670f0d52b045bbeb93af95ebb13ca362c5d626a 100644 --- a/apps/server/scripts/migrate-dev-db.ts +++ b/apps/server/scripts/migrate-dev-db.ts @@ -39,7 +39,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { Command, Flag } from "effect/unstable/cli"; import { migrationManifest, runMigrations } from "../src/persistence/Migrations.ts"; -import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; export class MigrateDevDbNotInWorktreeError extends Schema.TaggedErrorClass()( "MigrateDevDbNotInWorktreeError", diff --git a/apps/server/scripts/t3-sqlite-state.test.ts b/apps/server/scripts/t3-sqlite-state.test.ts index d1ef1368918bf411bc98bad56a3773e21a676fd6..ec236f4a67c0e8a61415b634a199282c67260905 100644 --- a/apps/server/scripts/t3-sqlite-state.test.ts +++ b/apps/server/scripts/t3-sqlite-state.test.ts @@ -5,7 +5,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import { runSqliteState } from "./t3-sqlite-state.ts"; const createFixtureDatabase = Effect.fn("createSqliteStateFixtureDatabase")(function* ( diff --git a/apps/server/scripts/t3-sqlite-state.ts b/apps/server/scripts/t3-sqlite-state.ts index de0402b36472aeaf2d62e26c918be3d6daab4be0..c34f4750c14cd731cd8b582ef67b7c815ea795ca 100644 --- a/apps/server/scripts/t3-sqlite-state.ts +++ b/apps/server/scripts/t3-sqlite-state.ts @@ -15,7 +15,7 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { Argument, Command, Flag } from "effect/unstable/cli"; -import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; export const SqliteStateOperation = Schema.Literals(["query", "exec"]); export type SqliteStateOperation = typeof SqliteStateOperation.Type; diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index ec1ffdefac0fec0c9c4ea4a1a7f28f0491e57394..41d8f5baf3fd49219f9f37211c3c78647c05892d 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -18,7 +18,7 @@ type Loader = { }; const defaultSqliteClientLoaders = { bun: () => import("@effect/sql-sqlite-bun/SqliteClient"), - node: () => import("../NodeSqliteClient.ts"), + node: () => import("@t3tools/shared/nodeSqliteClient"), } satisfies Record Promise>; const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* ( diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts index 5c5c6138ce78d177919dbbcd8c2d43d8739a2b5b..fff5a738622d142cba5d45ab2238db39ed76d7fb 100644 --- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts index 2011613a9f9ae78da2a3f171195ea2fad0671f2f..040a9fa47b92de279c111f0bba6f19cb3c68a6b7 100644 --- a/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts +++ b/apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts index 71dfe6fd004ea149ac0178876e4215f287b10caa..49585fb36f37430c2ad29163694856f4f2d390f2 100644 --- a/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts +++ b/apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts index 752b1676efaedd5c5be153f47ba3e6cfa2811025..efdf88bf6c3b2187984eaa48e4ffd7229eecbf15 100644 --- a/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts +++ b/apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts index 5160b4ab34b8a3e44c1561f3384c905cd1a8fa41..558183e216cc3321eddf165c76472f172be9ddf7 100644 --- a/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts +++ b/apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts index 5c0d7e2a7a8d0e99c9e572136e0f80f0a15d5814..b5e4f5cf3a22a9580f81145b8fabec3025c0ebd6 100644 --- a/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts +++ b/apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts b/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts index 4b0aa186cb4ba5b8f0ff985d5e033a0d82a24bef..7078450c96ec8775c96b4a7cdbbb786c5e76dac7 100644 --- a/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts +++ b/apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts b/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts index cb50dc2c6cdb669ba6c4eb0b59789e2898a54264..63eba11aac7192aaec3bcc525a10cdfe42be82d4 100644 --- a/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts +++ b/apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts index 755591201de2cc9acbd75ab5e2b1eb5d44a7de20..0e7f54812c21bdeaf7ab34440d53698a58454154 100644 --- a/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts +++ b/apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts index 7fd43d9b2ece976789f87d1017f499a63769ca36..42728812500736fc80b564919caf92820f97d6ac 100644 --- a/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts +++ b/apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts index 178338b783180a26b2bedbe1a49efe3f7f89d211..b19f1ce7cf2000185d98113ae44e9ef0e04c9744 100644 --- a/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts +++ b/apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts index 1fe59df5072935a4ffa13f22acecb7dd7bc82110..02e7f2f6c59f5e1738773940d13316ad5ffa16bf 100644 --- a/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts +++ b/apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts @@ -4,7 +4,7 @@ import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { runMigrations } from "../Migrations.ts"; -import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); diff --git a/apps/server/src/persistence/NodeSqliteClient.test.ts b/apps/server/src/persistence/NodeSqliteClient.test.ts deleted file mode 100644 index b17d3e0eb6a1890505d2124dd46e5b46e133e99c..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/NodeSqliteClient.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as SqlClient from "effect/unstable/sql/SqlClient"; - -import * as SqliteClient from "./NodeSqliteClient.ts"; - -const layer = it.layer(SqliteClient.layerMemory()); - -layer("NodeSqliteClient", (it) => { - it.effect("runs prepared queries and returns positional values", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - - yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT NOT NULL)`; - yield* sql`INSERT INTO entries(name) VALUES (${"alpha"}), (${"beta"})`; - - const rows = yield* sql<{ readonly id: number; readonly name: string }>` - SELECT id, name FROM entries ORDER BY id - `; - assert.equal(rows.length, 2); - assert.equal(rows[0]?.name, "alpha"); - assert.equal(rows[1]?.name, "beta"); - - const values = yield* sql`SELECT id, name FROM entries ORDER BY id`.values; - assert.equal(values.length, 2); - assert.equal(values[0]?.[1], "alpha"); - assert.equal(values[1]?.[1], "beta"); - - const unpreparedValues = yield* sql`SELECT id, name FROM entries ORDER BY id` - .valuesUnprepared; - assert.deepEqual(unpreparedValues, values); - }), - ); - - it.effect("returns a typed failure when an unprepared statement cannot be prepared", () => - Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const error = yield* Effect.flip(sql.unsafe("SELECT FROM").unprepared); - - assert.equal(error._tag, "SqlError"); - assert.equal(error.reason.operation, "prepare"); - }), - ); -}); - -it.effect("returns a typed failure when the database cannot be opened", () => - Effect.gen(function* () { - const error = yield* Effect.flip( - Layer.build(SqliteClient.layer({ filename: "\0" })).pipe(Effect.scoped), - ); - - assert.equal(error._tag, "SqlError"); - assert.equal(error.reason.operation, "open"); - }), -); diff --git a/apps/server/src/persistence/NodeSqliteClient.ts b/apps/server/src/persistence/NodeSqliteClient.ts deleted file mode 100644 index d2a5f9dcf95e1a0481e67001067186fb40d6cc4d..0000000000000000000000000000000000000000 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ /dev/null @@ -1,331 +0,0 @@ -/** - * 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 | 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", - { - 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", - {}, -) { - 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 { - 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(); - 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 prepare = (sql: string) => - Effect.try({ - try: () => db.prepare(sql), - catch: (cause) => - new SqlError({ - reason: classifySqliteError(cause, { - message: "Failed to prepare statement", - operation: "prepare", - }), - }), - }); - - const prepareCache = yield* Cache.make({ - capacity: options.prepareCacheSize ?? 200, - timeToLive: options.prepareCacheTTL ?? Duration.minutes(10), - lookup: prepare, - }); - - const runStatement = ( - statement: NodeSqlite.StatementSync, - params: ReadonlyArray, - raw: boolean, - ) => - Effect.withFiber, 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) : []); - } catch (cause) { - return Effect.fail( - new SqlError({ - reason: classifySqliteError(cause, { - message: "Failed to execute statement", - operation: "execute", - }), - }), - ); - } - }); - - const run = (sql: string, params: ReadonlyArray, raw = false) => - Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw)); - - const runStatementValues = ( - statement: NodeSqlite.StatementSync, - params: ReadonlyArray, - ) => - Effect.acquireUseRelease( - Effect.succeed(statement), - (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 - >; - } - 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), - ); - - const runValues = (sql: string, params: ReadonlyArray) => - Effect.flatMap(Cache.get(prepareCache, sql), (statement) => - runStatementValues(statement, params), - ); - - return identity({ - 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); - }, - executeValuesUnprepared(sql, params) { - return Effect.flatMap(prepare(sql), (statement) => - runStatementValues(statement, params ?? []), - ); - }, - executeUnprepared(sql, params, rowTransform) { - const effect = prepare(sql).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 => - makeWithDatabase( - options, - () => - new NodeSqlite.DatabaseSync(options.filename, { - readOnly: options.readonly ?? false, - allowExtension: options.allowExtension ?? false, - }), - ); - -const makeMemory = ( - config: SqliteMemoryClientConfig = {}, -): Effect.Effect => - makeWithDatabase( - { - ...config, - filename: ":memory:", - readonly: false, - }, - () => { - const database = new NodeSqlite.DatabaseSync(":memory:", { - allowExtension: config.allowExtension ?? false, - }); - return database; - }, - ); - -export const layerConfig = ( - config: Config.Wrap, -): Layer.Layer => - Layer.effect(Client.SqlClient, Config.unwrap(config).pipe(Effect.flatMap(make))).pipe( - Layer.provide(Reactivity.layer), - ); - -export const layer = (config: SqliteClientConfig): Layer.Layer => - Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer)); - -export const layerMemory = ( - config: SqliteMemoryClientConfig = {}, -): Layer.Layer => - Layer.effect(Client.SqlClient, makeMemory(config)).pipe(Layer.provide(Reactivity.layer)); diff --git a/packages/shared/package.json b/packages/shared/package.json index 0753cc85aabbb314d53ae25aa231b36ec5bf7c25..fda7a91b1a2fc82ee597ee02a52388159535575f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -246,6 +246,10 @@ "./claudeCompaction": { "types": "./src/claudeCompaction.ts", "import": "./src/claudeCompaction.ts" + }, + "./nodeSqliteClient": { + "types": "./src/nodeSqliteClient.ts", + "import": "./src/nodeSqliteClient.ts" } }, "scripts": { diff --git a/packages/shared/src/nodeSqliteClient.test.ts b/packages/shared/src/nodeSqliteClient.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6738892a9792f66a9efb7d1e9d21976824c29966 --- /dev/null +++ b/packages/shared/src/nodeSqliteClient.test.ts @@ -0,0 +1,56 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as SqliteClient from "./nodeSqliteClient.ts"; + +const layer = it.layer(SqliteClient.layerMemory()); + +layer("NodeSqliteClient", (it) => { + it.effect("runs prepared queries and returns positional values", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT NOT NULL)`; + yield* sql`INSERT INTO entries(name) VALUES (${"alpha"}), (${"beta"})`; + + const rows = yield* sql<{ readonly id: number; readonly name: string }>` + SELECT id, name FROM entries ORDER BY id + `; + assert.equal(rows.length, 2); + assert.equal(rows[0]?.name, "alpha"); + assert.equal(rows[1]?.name, "beta"); + + const values = yield* sql`SELECT id, name FROM entries ORDER BY id`.values; + assert.equal(values.length, 2); + assert.equal(values[0]?.[1], "alpha"); + assert.equal(values[1]?.[1], "beta"); + + const unpreparedValues = yield* sql`SELECT id, name FROM entries ORDER BY id` + .valuesUnprepared; + assert.deepEqual(unpreparedValues, values); + }), + ); + + it.effect("returns a typed failure when an unprepared statement cannot be prepared", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const error = yield* Effect.flip(sql.unsafe("SELECT FROM").unprepared); + + assert.equal(error._tag, "SqlError"); + assert.equal(error.reason.operation, "prepare"); + }), + ); +}); + +it.effect("returns a typed failure when the database cannot be opened", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + Layer.build(SqliteClient.layer({ filename: "\0" })).pipe(Effect.scoped), + ); + + assert.equal(error._tag, "SqlError"); + assert.equal(error.reason.operation, "open"); + }), +); diff --git a/packages/shared/src/nodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2a5f9dcf95e1a0481e67001067186fb40d6cc4d --- /dev/null +++ b/packages/shared/src/nodeSqliteClient.ts @@ -0,0 +1,331 @@ +/** + * 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 | 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", + { + 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", + {}, +) { + 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 { + 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(); + 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 prepare = (sql: string) => + Effect.try({ + try: () => db.prepare(sql), + catch: (cause) => + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to prepare statement", + operation: "prepare", + }), + }), + }); + + const prepareCache = yield* Cache.make({ + capacity: options.prepareCacheSize ?? 200, + timeToLive: options.prepareCacheTTL ?? Duration.minutes(10), + lookup: prepare, + }); + + const runStatement = ( + statement: NodeSqlite.StatementSync, + params: ReadonlyArray, + raw: boolean, + ) => + Effect.withFiber, 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) : []); + } catch (cause) { + return Effect.fail( + new SqlError({ + reason: classifySqliteError(cause, { + message: "Failed to execute statement", + operation: "execute", + }), + }), + ); + } + }); + + const run = (sql: string, params: ReadonlyArray, raw = false) => + Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw)); + + const runStatementValues = ( + statement: NodeSqlite.StatementSync, + params: ReadonlyArray, + ) => + Effect.acquireUseRelease( + Effect.succeed(statement), + (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 + >; + } + 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), + ); + + const runValues = (sql: string, params: ReadonlyArray) => + Effect.flatMap(Cache.get(prepareCache, sql), (statement) => + runStatementValues(statement, params), + ); + + return identity({ + 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); + }, + executeValuesUnprepared(sql, params) { + return Effect.flatMap(prepare(sql), (statement) => + runStatementValues(statement, params ?? []), + ); + }, + executeUnprepared(sql, params, rowTransform) { + const effect = prepare(sql).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 => + makeWithDatabase( + options, + () => + new NodeSqlite.DatabaseSync(options.filename, { + readOnly: options.readonly ?? false, + allowExtension: options.allowExtension ?? false, + }), + ); + +const makeMemory = ( + config: SqliteMemoryClientConfig = {}, +): Effect.Effect => + makeWithDatabase( + { + ...config, + filename: ":memory:", + readonly: false, + }, + () => { + const database = new NodeSqlite.DatabaseSync(":memory:", { + allowExtension: config.allowExtension ?? false, + }); + return database; + }, + ); + +export const layerConfig = ( + config: Config.Wrap, +): Layer.Layer => + Layer.effect(Client.SqlClient, Config.unwrap(config).pipe(Effect.flatMap(make))).pipe( + Layer.provide(Reactivity.layer), + ); + +export const layer = (config: SqliteClientConfig): Layer.Layer => + Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer)); + +export const layerMemory = ( + config: SqliteMemoryClientConfig = {}, +): Layer.Layer => + Layer.effect(Client.SqlClient, makeMemory(config)).pipe(Layer.provide(Reactivity.layer));