From abc253d97dc1d976cf15e69276f2ac7375b26310 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:12:41 -0700 Subject: [PATCH] [codex] Preserve native SQLite failure context (#3428) Co-authored-by: codex --- apps/server/src/persistence/Layers/Sqlite.ts | 3 +- .../src/persistence/NodeSqliteClient.test.ts | 22 +++++ .../src/persistence/NodeSqliteClient.ts | 98 +++++++++++++++---- 3 files changed, 104 insertions(+), 19 deletions(-) diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 3bc1ec4d2d2ac2f3457053d52bf3330fe1124370..dfd338b7159f2c88e5c33a99cc49578f76793c50 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -3,6 +3,7 @@ import * as Layer from "effect/Layer"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; import { runMigrations } from "../Migrations.ts"; import { ServerConfig } from "../../config.ts"; @@ -13,7 +14,7 @@ type RuntimeSqliteLayerConfig = { }; type Loader = { - layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer; + layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer; }; const defaultSqliteClientLoaders = { bun: () => import("@effect/sql-sqlite-bun/SqliteClient"), diff --git a/apps/server/src/persistence/NodeSqliteClient.test.ts b/apps/server/src/persistence/NodeSqliteClient.test.ts index 43023abf60a1e56c00cab4bdbf7e1489220fc045..ce52c36d84cec1c6b4685116051deaaa9b636ad5 100644 --- a/apps/server/src/persistence/NodeSqliteClient.test.ts +++ b/apps/server/src/persistence/NodeSqliteClient.test.ts @@ -1,5 +1,6 @@ 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"; @@ -27,4 +28,25 @@ layer("NodeSqliteClient", (it) => { assert.equal(values[1]?.[1], "beta"); }), ); + + 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 index f3d03e1c695a9fd0dd4a6c7cef76ad4523f0b3b8..16d5762a1fee9ea4b20f6da1be783ff4ff252d78 100644 --- a/apps/server/src/persistence/NodeSqliteClient.ts +++ b/apps/server/src/persistence/NodeSqliteClient.ts @@ -13,6 +13,7 @@ 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"; @@ -45,6 +46,27 @@ export interface SqliteMemoryClientConfig extends Omit< "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 @@ -60,8 +82,10 @@ const checkNodeSqliteCompat = () => { if (!supported) { return Effect.die( - `Node.js ${process.versions.node} is missing required node:sqlite APIs ` + - `(StatementSync.columns). Upgrade to Node.js >=22.16, >=23.11, or >=24.`, + new UnsupportedNodeSqliteVersionError({ + nodeVersion: process.versions.node, + requirement: "Node.js >=22.16, >=23.11, or >=24", + }), ); } return Effect.void; @@ -70,7 +94,7 @@ const checkNodeSqliteCompat = () => { const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( options: SqliteClientConfig, openDatabase: () => NodeSqlite.DatabaseSync, -): Effect.fn.Return { +): Effect.fn.Return { yield* checkNodeSqliteCompat(); const compiler = Statement.makeCompilerSqlite(options.transformQueryNames); @@ -80,10 +104,28 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( const makeConnection = Effect.gen(function* () { const scope = yield* Effect.scope; - const db = openDatabase(); + 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.sync(() => db.close()), + 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(); @@ -119,8 +161,8 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( raw: boolean, ) => Effect.withFiber, SqlError>((fiber) => { - statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers))); try { + statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers))); if (hasRows(statement)) { return Effect.succeed(statement.all(...(params as any))); } @@ -166,11 +208,20 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( }), }), (statement) => - Effect.sync(() => { - if (hasRows(statement)) { - statement.setReturnArrays(false); - } - }), + 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({ @@ -184,11 +235,20 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( return runValues(sql, params); }, executeUnprepared(sql, params, rowTransform) { - const effect = runStatement(db.prepare(sql), params ?? [], false); + 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("executeStream not implemented"); + return Stream.die(new UnsupportedNodeSqliteOperationError()); }, }); }); @@ -220,7 +280,7 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* ( const make = ( options: SqliteClientConfig, -): Effect.Effect => +): Effect.Effect => makeWithDatabase( options, () => @@ -232,7 +292,7 @@ const make = ( const makeMemory = ( config: SqliteMemoryClientConfig = {}, -): Effect.Effect => +): Effect.Effect => makeWithDatabase( { ...config, @@ -249,13 +309,15 @@ const makeMemory = ( export const layerConfig = ( config: Config.Wrap, -): Layer.Layer => +): 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 => +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 => +export const layerMemory = ( + config: SqliteMemoryClientConfig = {}, +): Layer.Layer => Layer.effect(Client.SqlClient, makeMemory(config)).pipe(Layer.provide(Reactivity.layer)); -- 2.54.0