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";
33import * as FileSystem from "effect/FileSystem";
44import * as Path from "effect/Path";
55import * as SqlClient from "effect/unstable/sql/SqlClient";
6import type { SqlError } from "effect/unstable/sql/SqlError";
67
78import { runMigrations } from "../Migrations.ts";
89import { ServerConfig } from "../../config.ts";
......@@ -13,7 +14,7 @@ type RuntimeSqliteLayerConfig = {
1314};
1415
1516type Loader = {
16 layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer<SqlClient.SqlClient>;
17 layer: (config: RuntimeSqliteLayerConfig) => Layer.Layer<SqlClient.SqlClient, SqlError>;
1718};
1819const defaultSqliteClientLoaders = {
1920 bun: () => import("@effect/sql-sqlite-bun/SqliteClient"),
apps/server/src/persistence/NodeSqliteClient.test.ts+22
......@@ -1,5 +1,6 @@
11import { assert, it } from "@effect/vitest";
22import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
34import * as SqlClient from "effect/unstable/sql/SqlClient";
45
56import * as SqliteClient from "./NodeSqliteClient.ts";
......@@ -27,4 +28,25 @@ layer("NodeSqliteClient", (it) => {
2728 assert.equal(values[1]?.[1], "beta");
2829 }),
2930 );
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 );
3041});
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";
1313import * as Fiber from "effect/Fiber";
1414import { identity } from "effect/Function";
1515import * as Layer from "effect/Layer";
16import * as Schema from "effect/Schema";
1617import * as Scope from "effect/Scope";
1718import * as Semaphore from "effect/Semaphore";
1819import * as Context from "effect/Context";
......@@ -45,6 +46,27 @@ export interface SqliteMemoryClientConfig extends Omit<
4546 "filename" | "readonly"
4647> {}
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
4870/**
4971 * Verify that the current Node.js version includes the `node:sqlite` APIs
5072 * used by `NodeSqliteClient` — specifically `StatementSync.columns()` (added
......@@ -60,8 +82,10 @@ const checkNodeSqliteCompat = () => {
6082
6183 if (!supported) {
6284 return Effect.die(
63 `Node.js ${process.versions.node} is missing required node:sqlite APIs ` +
64 `(StatementSync.columns). Upgrade to Node.js >=22.16, >=23.11, or >=24.`,
85 new UnsupportedNodeSqliteVersionError({
86 nodeVersion: process.versions.node,
87 requirement: "Node.js >=22.16, >=23.11, or >=24",
88 }),
6589 );
6690 }
6791 return Effect.void;
......@@ -70,7 +94,7 @@ const checkNodeSqliteCompat = () => {
7094const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
7195 options: SqliteClientConfig,
7296 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> {
7498 yield* checkNodeSqliteCompat();
7599
76100 const compiler = Statement.makeCompilerSqlite(options.transformQueryNames);
......@@ -80,10 +104,28 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
80104
81105 const makeConnection = Effect.gen(function* () {
82106 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 });
84117 yield* Scope.addFinalizer(
85118 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),
87129 );
88130
89131 const statementReaderCache = new WeakMap<NodeSqlite.StatementSync, boolean>();
......@@ -119,8 +161,8 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
119161 raw: boolean,
120162 ) =>
121163 Effect.withFiber<ReadonlyArray<any>, SqlError>((fiber) => {
122 statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers)));
123164 try {
165 statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers)));
124166 if (hasRows(statement)) {
125167 return Effect.succeed(statement.all(...(params as any)));
126168 }
......@@ -166,11 +208,20 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
166208 }),
167209 }),
168210 (statement) =>
169 Effect.sync(() => {
170 if (hasRows(statement)) {
171 statement.setReturnArrays(false);
172 }
173 }),
211 Effect.try({
212 try: () => {
213 if (hasRows(statement)) {
214 statement.setReturnArrays(false);
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),
174225 );
175226
176227 return identity<Connection>({
......@@ -184,11 +235,20 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
184235 return runValues(sql, params);
185236 },
186237 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)));
188248 return rowTransform ? Effect.map(effect, rowTransform) : effect;
189249 },
190250 executeStream(_sql, _params) {
191 return Stream.die("executeStream not implemented");
251 return Stream.die(new UnsupportedNodeSqliteOperationError());
192252 },
193253 });
194254 });
......@@ -220,7 +280,7 @@ const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
220280
221281const make = (
222282 options: SqliteClientConfig,
223): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> =>
283): Effect.Effect<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>
224284 makeWithDatabase(
225285 options,
226286 () =>
......@@ -232,7 +292,7 @@ const make = (
232292
233293const makeMemory = (
234294 config: SqliteMemoryClientConfig = {},
235): Effect.Effect<Client.SqlClient, never, Scope.Scope | Reactivity.Reactivity> =>
295): Effect.Effect<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>
236296 makeWithDatabase(
237297 {
238298 ...config,
......@@ -249,13 +309,15 @@ const makeMemory = (
249309
250310export const layerConfig = (
251311 config: Config.Wrap<SqliteClientConfig>,
252): Layer.Layer<Client.SqlClient, Config.ConfigError> =>
312): Layer.Layer<Client.SqlClient, Config.ConfigError | SqlError> =>
253313 Layer.effect(Client.SqlClient, Config.unwrap(config).pipe(Effect.flatMap(make))).pipe(
254314 Layer.provide(Reactivity.layer),
255315 );
256316
257export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient> =>
317export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient, SqlError> =>
258318 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> =>
261323 Layer.effect(Client.SqlClient, makeMemory(config)).pipe(Layer.provide(Reactivity.layer));