authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:12:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:12:41-07:00
logabc253d97dc1d976cf15e69276f2ac7375b26310
treeac2c604dd50b3ebac0fb55e61f55f3164cc84b14
parentce11e6facd6e557c382b3995242e2b501317fecd
signature Signed by PGP key B5690EEEBB952194

[codex] Preserve native SQLite failure context (#3428)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 104 insertions(+), 19 deletions(-)

apps/server/src/persistence/Layers/Sqlite.ts+2-1
...@@ -3,6 +3,7 @@ import * as Layer from "effect/Layer";...@@ -3,6 +3,7 @@ import * as Layer from "effect/Layer";
3import * as FileSystem from "effect/FileSystem";3import * as FileSystem from "effect/FileSystem";
4import * as Path from "effect/Path";4import * as Path from "effect/Path";
5import * as SqlClient from "effect/unstable/sql/SqlClient";5import * as SqlClient from "effect/unstable/sql/SqlClient";
6import type { SqlError } from "effect/unstable/sql/SqlError";
67
7import { runMigrations } from "../Migrations.ts";8import { runMigrations } from "../Migrations.ts";
8import { ServerConfig } from "../../config.ts";9import { ServerConfig } from "../../config.ts";
...@@ -13,7 +14,7 @@ type RuntimeSqliteLayerConfig = {...@@ -13,7 +14,7 @@ type RuntimeSqliteLayerConfig = {
13};14};
1415
15type Loader = {16type Loader = {
16 layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer<SqlClient.SqlClient>;17 layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer<SqlClient.SqlClient, SqlError>;
17};18};
18const defaultSqliteClientLoaders = {19const defaultSqliteClientLoaders = {
19 bun: () => import("@effect/sql-sqlite-bun/SqliteClient"),20 bun: () => import("@effect/sql-sqlite-bun/SqliteClient"),
apps/server/src/persistence/NodeSqliteClient.test.ts+22
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
3import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
45
5import * as SqliteClient from "./NodeSqliteClient.ts";6import * as SqliteClient from "./NodeSqliteClient.ts";
...@@ -27,4 +28,25 @@ layer("NodeSqliteClient", (it) => {...@@ -27,4 +28,25 @@ layer("NodeSqliteClient", (it) => {
27 assert.equal(values[1]?.[1], "beta");28 assert.equal(values[1]?.[1], "beta");
28 }),29 }),
29 );30 );
31
32 it.effect("returns a typed failure when an unprepared statement cannot be prepared", () =>
33 Effect.gen(function* () {
34 const sql = yield* SqlClient.SqlClient;
35 const error = yield* Effect.flip(sql.unsafe("SELECT FROM").unprepared);
36
37 assert.equal(error._tag, "SqlError");
38 assert.equal(error.reason.operation, "prepare");
39 }),
40 );
30});41});
42
43it.effect("returns a typed failure when the database cannot be opened", () =>
44 Effect.gen(function* () {
45 const error = yield* Effect.flip(
46 Layer.build(SqliteClient.layer({ filename: "\0" })).pipe(Effect.scoped),
47 );
48
49 assert.equal(error._tag, "SqlError");
50 assert.equal(error.reason.operation, "open");
51 }),
52);
apps/server/src/persistence/NodeSqliteClient.ts+80-18
...@@ -13,6 +13,7 @@ import * as Effect from "effect/Effect";...@@ -13,6 +13,7 @@ import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";13import * as Fiber from "effect/Fiber";
14import { identity } from "effect/Function";14import { identity } from "effect/Function";
15import * as Layer from "effect/Layer";15import * as Layer from "effect/Layer";
16import * as Schema from "effect/Schema";
16import * as Scope from "effect/Scope";17import * as Scope from "effect/Scope";
17import * as Semaphore from "effect/Semaphore";18import * as Semaphore from "effect/Semaphore";
18import * as Context from "effect/Context";19import * as Context from "effect/Context";
...@@ -45,6 +46,27 @@ export interface SqliteMemoryClientConfig extends Omit<...@@ -45,6 +46,27 @@ export interface SqliteMemoryClientConfig extends Omit<
45 "filename" | "readonly"46 "filename" | "readonly"
46> {}47> {}
4748
49export class UnsupportedNodeSqliteVersionError extends Schema.TaggedErrorClass<UnsupportedNodeSqliteVersionError>()(
50 "UnsupportedNodeSqliteVersionError",
51 {
52 nodeVersion: Schema.String,
53 requirement: Schema.String,
54 },
55) {
56 override get message(): string {
57 return `Node.js ${this.nodeVersion} is missing required node:sqlite APIs. Upgrade to ${this.requirement}.`;
58 }
59}
60
61export class UnsupportedNodeSqliteOperationError extends Schema.TaggedErrorClass<UnsupportedNodeSqliteOperationError>()(
62 "UnsupportedNodeSqliteOperationError",
63 {},
64) {
65 override get message(): string {
66 return "Node SQLite does not support executeStream.";
67 }
68}
69
48/**70/**
49 * Verify that the current Node.js version includes the `node:sqlite` APIs71 * Verify that the current Node.js version includes the `node:sqlite` APIs
50 * used by `NodeSqliteClient` — specifically `StatementSync.columns()` (added72 * used by `NodeSqliteClient` — specifically `StatementSync.columns()` (added
...@@ -60,8 +82,10 @@ const checkNodeSqliteCompat = () => {...@@ -60,8 +82,10 @@ const checkNodeSqliteCompat = () => {
6082
61 if (!supported) {83 if (!supported) {
62 return Effect.die(84 return Effect.die(
63 `Node.js ${process.versions.node} is missing required node:sqlite APIs ` +85 new UnsupportedNodeSqliteVersionError({
64 `(StatementSync.columns). Upgrade to Node.js >=22.16, >=23.11, or >=24.`,86 nodeVersion: process.versions.node,
87 requirement: "Node.js >=22.16, >=23.11, or >=24",
88 }),
65 );89 );
66 }90 }
67 return Effect.void;91 return Effect.void;
...@@ -70,7 +94,7 @@ const checkNodeSqliteCompat = () => {...@@ -70,7 +94,7 @@ const checkNodeSqliteCompat = () => {
70const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (94const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
71 options: SqliteClientConfig,95 options: SqliteClientConfig,
72 openDatabase: () => NodeSqlite.DatabaseSync,96 openDatabase: () => NodeSqlite.DatabaseSync,
73): Effect.fn.Return<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> {97): Effect.fn.Return<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> {
74 yield* checkNodeSqliteCompat();98 yield* checkNodeSqliteCompat();
7599
76 const compiler = Statement.makeCompilerSqlite(options.transformQueryNames);100 const compiler = Statement.makeCompilerSqlite(options.transformQueryNames);
...@@ -80,10 +104,28 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (...@@ -80,10 +104,28 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
80104
81 const makeConnection = Effect.gen(function* () {105 const makeConnection = Effect.gen(function* () {
82 const scope = yield* Effect.scope;106 const scope = yield* Effect.scope;
83 const db = openDatabase();107 const db = yield* Effect.try({
108 try: openDatabase,
109 catch: (cause) =>
110 new SqlError({
111 reason: classifySqliteError(cause, {
112 message: "Failed to open database",
113 operation: "open",
114 }),
115 }),
116 });
84 yield* Scope.addFinalizer(117 yield* Scope.addFinalizer(
85 scope,118 scope,
86 Effect.sync(() => db.close()),119 Effect.try({
120 try: () => db.close(),
121 catch: (cause) =>
122 new SqlError({
123 reason: classifySqliteError(cause, {
124 message: "Failed to close database",
125 operation: "close",
126 }),
127 }),
128 }).pipe(Effect.orDie),
87 );129 );
88130
89 const statementReaderCache = new WeakMap<NodeSqlite.StatementSync, boolean>();131 const statementReaderCache = new WeakMap<NodeSqlite.StatementSync, boolean>();
...@@ -119,8 +161,8 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (...@@ -119,8 +161,8 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
119 raw: boolean,161 raw: boolean,
120 ) =>162 ) =>
121 Effect.withFiber<ReadonlyArray<any>, SqlError>((fiber) => {163 Effect.withFiber<ReadonlyArray<any>, SqlError>((fiber) => {
122 statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers)));
123 try {164 try {
165 statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers)));
124 if (hasRows(statement)) {166 if (hasRows(statement)) {
125 return Effect.succeed(statement.all(...(params as any)));167 return Effect.succeed(statement.all(...(params as any)));
126 }168 }
...@@ -166,11 +208,20 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (...@@ -166,11 +208,20 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
166 }),208 }),
167 }),209 }),
168 (statement) =>210 (statement) =>
169 Effect.sync(() => {211 Effect.try({
170 if (hasRows(statement)) {212 try: () => {
171 statement.setReturnArrays(false);213 if (hasRows(statement)) {
172 }214 statement.setReturnArrays(false);
173 }),215 }
216 },
217 catch: (cause) =>
218 new SqlError({
219 reason: classifySqliteError(cause, {
220 message: "Failed to reset statement result mode",
221 operation: "resetResultMode",
222 }),
223 }),
224 }).pipe(Effect.orDie),
174 );225 );
175226
176 return identity<Connection>({227 return identity<Connection>({
...@@ -184,11 +235,20 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (...@@ -184,11 +235,20 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
184 return runValues(sql, params);235 return runValues(sql, params);
185 },236 },
186 executeUnprepared(sql, params, rowTransform) {237 executeUnprepared(sql, params, rowTransform) {
187 const effect = runStatement(db.prepare(sql), params ?? [], false);238 const effect = Effect.try({
239 try: () => db.prepare(sql),
240 catch: (cause) =>
241 new SqlError({
242 reason: classifySqliteError(cause, {
243 message: "Failed to prepare statement",
244 operation: "prepare",
245 }),
246 }),
247 }).pipe(Effect.flatMap((statement) => runStatement(statement, params ?? [], false)));
188 return rowTransform ? Effect.map(effect, rowTransform) : effect;248 return rowTransform ? Effect.map(effect, rowTransform) : effect;
189 },249 },
190 executeStream(_sql, _params) {250 executeStream(_sql, _params) {
191 return Stream.die("executeStream not implemented");251 return Stream.die(new UnsupportedNodeSqliteOperationError());
192 },252 },
193 });253 });
194 });254 });
...@@ -220,7 +280,7 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (...@@ -220,7 +280,7 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
220280
221const make = (281const make = (
222 options: SqliteClientConfig,282 options: SqliteClientConfig,
223): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> =>283): Effect.Effect<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>
224 makeWithDatabase(284 makeWithDatabase(
225 options,285 options,
226 () =>286 () =>
...@@ -232,7 +292,7 @@ const make = (...@@ -232,7 +292,7 @@ const make = (
232292
233const makeMemory = (293const makeMemory = (
234 config: SqliteMemoryClientConfig = {},294 config: SqliteMemoryClientConfig = {},
235): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> =>295): Effect.Effect<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>
236 makeWithDatabase(296 makeWithDatabase(
237 {297 {
238 ...config,298 ...config,
...@@ -249,13 +309,15 @@ const makeMemory = (...@@ -249,13 +309,15 @@ const makeMemory = (
249309
250export const layerConfig = (310export const layerConfig = (
251 config: Config.Wrap<SqliteClientConfig>,311 config: Config.Wrap<SqliteClientConfig>,
252): Layer.Layer<Client.SqlClient, Config.ConfigError> =>312): Layer.Layer<Client.SqlClient, Config.ConfigError | SqlError> =>
253 Layer.effect(Client.SqlClient, Config.unwrap(config).pipe(Effect.flatMap(make))).pipe(313 Layer.effect(Client.SqlClient, Config.unwrap(config).pipe(Effect.flatMap(make))).pipe(
254 Layer.provide(Reactivity.layer),314 Layer.provide(Reactivity.layer),
255 );315 );
256316
257export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient> =>317export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient, SqlError> =>
258 Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer));318 Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer));
259319
260export const layerMemory = (config: SqliteMemoryClientConfig = {}): Layer.Layer<Client.SqlClient> =>320export const layerMemory = (
321 config: SqliteMemoryClientConfig = {},
322): Layer.Layer<Client.SqlClient, SqlError> =>
261 Layer.effect(Client.SqlClient, makeMemory(config)).pipe(Layer.provide(Reactivity.layer));323 Layer.effect(Client.SqlClient, makeMemory(config)).pipe(Layer.provide(Reactivity.layer));