authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 14:56:45-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 14:56:45-07:00
logca63d42d670837b918081d1fc1ebada553814b4c
treee3722bbd2e014c2379504e2af273473a9bba964a
parent134d51096ea0d00a53a499e8f0c87e31fafb0006
signature Signed by PGP key B5690EEEBB952194

refactor(shared): move the node:sqlite Effect SQL client into shared (#7272)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

22 files changed, 408 insertions(+), 404 deletions(-)

apps/server/scripts/migrate-dev-db.test.ts+1-1
...@@ -6,7 +6,7 @@ import * as Path from "effect/Path";...@@ -6,7 +6,7 @@ import * as Path from "effect/Path";
6import * as SqlClient from "effect/unstable/sql/SqlClient";6import * as SqlClient from "effect/unstable/sql/SqlClient";
77
8import { runMigrations } from "../src/persistence/Migrations.ts";8import { runMigrations } from "../src/persistence/Migrations.ts";
9import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts";9import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
10import { runMigrateDevDb } from "./migrate-dev-db.ts";10import { runMigrateDevDb } from "./migrate-dev-db.ts";
1111
12const withDatabase = <A, E>(12const withDatabase = <A, E>(
apps/server/scripts/migrate-dev-db.ts+1-1
...@@ -39,7 +39,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";...@@ -39,7 +39,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
39import { Command, Flag } from "effect/unstable/cli";39import { Command, Flag } from "effect/unstable/cli";
4040
41import { migrationManifest, runMigrations } from "../src/persistence/Migrations.ts";41import { migrationManifest, runMigrations } from "../src/persistence/Migrations.ts";
42import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts";42import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
4343
44export class MigrateDevDbNotInWorktreeError extends Schema.TaggedErrorClass<MigrateDevDbNotInWorktreeError>()(44export class MigrateDevDbNotInWorktreeError extends Schema.TaggedErrorClass<MigrateDevDbNotInWorktreeError>()(
45 "MigrateDevDbNotInWorktreeError",45 "MigrateDevDbNotInWorktreeError",
apps/server/scripts/t3-sqlite-state.test.ts+1-1
...@@ -5,7 +5,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -5,7 +5,7 @@ import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";5import * as Path from "effect/Path";
6import * as SqlClient from "effect/unstable/sql/SqlClient";6import * as SqlClient from "effect/unstable/sql/SqlClient";
77
8import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts";8import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
9import { runSqliteState } from "./t3-sqlite-state.ts";9import { runSqliteState } from "./t3-sqlite-state.ts";
1010
11const createFixtureDatabase = Effect.fn("createSqliteStateFixtureDatabase")(function* (11const createFixtureDatabase = Effect.fn("createSqliteStateFixtureDatabase")(function* (
apps/server/scripts/t3-sqlite-state.ts+1-1
...@@ -15,7 +15,7 @@ import * as Schema from "effect/Schema";...@@ -15,7 +15,7 @@ import * as Schema from "effect/Schema";
15import * as SqlClient from "effect/unstable/sql/SqlClient";15import * as SqlClient from "effect/unstable/sql/SqlClient";
16import { Argument, Command, Flag } from "effect/unstable/cli";16import { Argument, Command, Flag } from "effect/unstable/cli";
1717
18import * as NodeSqliteClient from "../src/persistence/NodeSqliteClient.ts";18import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
1919
20export const SqliteStateOperation = Schema.Literals(["query", "exec"]);20export const SqliteStateOperation = Schema.Literals(["query", "exec"]);
21export type SqliteStateOperation = typeof SqliteStateOperation.Type;21export type SqliteStateOperation = typeof SqliteStateOperation.Type;
apps/server/src/persistence/Layers/Sqlite.ts+1-1
...@@ -18,7 +18,7 @@ type Loader = {...@@ -18,7 +18,7 @@ type Loader = {
18};18};
19const defaultSqliteClientLoaders = {19const defaultSqliteClientLoaders = {
20 bun: () => import("@effect/sql-sqlite-bun/SqliteClient"),20 bun: () => import("@effect/sql-sqlite-bun/SqliteClient"),
21 node: () => import("../NodeSqliteClient.ts"),21 node: () => import("@t3tools/shared/nodeSqliteClient"),
22} satisfies Record<string, () => Promise<Loader>>;22} satisfies Record<string, () => Promise<Loader>>;
2323
24const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* (24const makeRuntimeSqliteLayer = Effect.fn("makeRuntimeSqliteLayer")(function* (
apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts+1-1
...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
55
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
apps/server/src/persistence/Migrations/019_ProjectionSnapshotLookupIndexes.test.ts+1-1
...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
55
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts+1-1
...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
55
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts+1-1
...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
55
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
apps/server/src/persistence/Migrations/026_CanonicalizeModelSelectionOptions.test.ts+1-1
...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
55
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
apps/server/src/persistence/Migrations/027_028_ProviderInstanceIdColumns.test.ts+1-1
...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
55
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
apps/server/src/persistence/Migrations/029_ProjectionThreadDetailOrderingIndexes.test.ts+1-1
...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
55
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
apps/server/src/persistence/Migrations/031_AuthAuthorizationScopes.test.ts+1-1
...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
55
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
apps/server/src/persistence/Migrations/035_ProjectionThreadTitleRegeneration.test.ts+1-1
...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
55
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
apps/server/src/persistence/Migrations/040_ProjectionProjectFaviconPath.test.ts+1-1
...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
55
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
apps/server/src/persistence/Migrations/041_AuthSessionClientConnection.test.ts+1-1
...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
55
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
apps/server/src/persistence/Migrations/042_ProjectionThreadLinkedPullRequest.test.ts+1-1
...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";...@@ -4,7 +4,7 @@ import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
55
6import { runMigrations } from "../Migrations.ts";6import { runMigrations } from "../Migrations.ts";
7import * as NodeSqliteClient from "../NodeSqliteClient.ts";7import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
88
9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));9const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
1010
apps/server/src/persistence/NodeSqliteClient.test.ts deleted-56
...@@ -1,56 +0,0 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import * as SqliteClient from "./NodeSqliteClient.ts";
7
8const layer = it.layer(SqliteClient.layerMemory());
9
10layer("NodeSqliteClient", (it) => {
11 it.effect("runs prepared queries and returns positional values", () =>
12 Effect.gen(function* () {
13 const sql = yield* SqlClient.SqlClient;
14
15 yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT NOT NULL)`;
16 yield* sql`INSERT INTO entries(name) VALUES (${"alpha"}), (${"beta"})`;
17
18 const rows = yield* sql<{ readonly id: number; readonly name: string }>`
19 SELECT id, name FROM entries ORDER BY id
20 `;
21 assert.equal(rows.length, 2);
22 assert.equal(rows[0]?.name, "alpha");
23 assert.equal(rows[1]?.name, "beta");
24
25 const values = yield* sql`SELECT id, name FROM entries ORDER BY id`.values;
26 assert.equal(values.length, 2);
27 assert.equal(values[0]?.[1], "alpha");
28 assert.equal(values[1]?.[1], "beta");
29
30 const unpreparedValues = yield* sql`SELECT id, name FROM entries ORDER BY id`
31 .valuesUnprepared;
32 assert.deepEqual(unpreparedValues, values);
33 }),
34 );
35
36 it.effect("returns a typed failure when an unprepared statement cannot be prepared", () =>
37 Effect.gen(function* () {
38 const sql = yield* SqlClient.SqlClient;
39 const error = yield* Effect.flip(sql.unsafe("SELECT FROM").unprepared);
40
41 assert.equal(error._tag, "SqlError");
42 assert.equal(error.reason.operation, "prepare");
43 }),
44 );
45});
46
47it.effect("returns a typed failure when the database cannot be opened", () =>
48 Effect.gen(function* () {
49 const error = yield* Effect.flip(
50 Layer.build(SqliteClient.layer({ filename: "\0" })).pipe(Effect.scoped),
51 );
52
53 assert.equal(error._tag, "SqlError");
54 assert.equal(error.reason.operation, "open");
55 }),
56);
apps/server/src/persistence/NodeSqliteClient.ts deleted-331
...@@ -1,331 +0,0 @@
1/**
2 * Port of `@effect/sql-sqlite-node` that uses the native `node:sqlite`
3 * bindings instead of `better-sqlite3`.
4 *
5 * @module SqliteClient
6 */
7import * as NodeSqlite from "node:sqlite";
8
9import * as Cache from "effect/Cache";
10import * as Config from "effect/Config";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";
14import { identity } from "effect/Function";
15import * as Layer from "effect/Layer";
16import * as Schema from "effect/Schema";
17import * as Scope from "effect/Scope";
18import * as Semaphore from "effect/Semaphore";
19import * as Context from "effect/Context";
20import * as Stream from "effect/Stream";
21import * as Reactivity from "effect/unstable/reactivity/Reactivity";
22import * as Client from "effect/unstable/sql/SqlClient";
23import type { Connection } from "effect/unstable/sql/SqlConnection";
24import { SqlError, classifySqliteError } from "effect/unstable/sql/SqlError";
25import * as Statement from "effect/unstable/sql/Statement";
26
27const ATTR_DB_SYSTEM_NAME = "db.system.name";
28
29export const TypeId: TypeId = "~local/sqlite-node/SqliteClient";
30
31export type TypeId = "~local/sqlite-node/SqliteClient";
32
33export interface SqliteClientConfig {
34 readonly filename: string;
35 readonly readonly?: boolean | undefined;
36 readonly allowExtension?: boolean | undefined;
37 readonly prepareCacheSize?: number | undefined;
38 readonly prepareCacheTTL?: Duration.Input | undefined;
39 readonly spanAttributes?: Record<string, unknown> | undefined;
40 readonly transformResultNames?: ((str: string) => string) | undefined;
41 readonly transformQueryNames?: ((str: string) => string) | undefined;
42}
43
44export interface SqliteMemoryClientConfig extends Omit<
45 SqliteClientConfig,
46 "filename" | "readonly"
47> {}
48
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
70/**
71 * Verify that the current Node.js version includes the `node:sqlite` APIs
72 * used by `NodeSqliteClient` — specifically `StatementSync.columns()` (added
73 * in Node 22.16.0 / 23.11.0).
74 *
75 * @see https://github.com/nodejs/node/pull/57490
76 */
77const checkNodeSqliteCompat = () => {
78 const parts = process.versions.node.split(".").map(Number);
79 const major = parts[0] ?? 0;
80 const minor = parts[1] ?? 0;
81 const supported = (major === 22 && minor >= 16) || (major === 23 && minor >= 11) || major >= 24;
82
83 if (!supported) {
84 return Effect.die(
85 new UnsupportedNodeSqliteVersionError({
86 nodeVersion: process.versions.node,
87 requirement: "Node.js >=22.16, >=23.11, or >=24",
88 }),
89 );
90 }
91 return Effect.void;
92};
93
94const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
95 options: SqliteClientConfig,
96 openDatabase: () => NodeSqlite.DatabaseSync,
97): Effect.fn.Return<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> {
98 yield* checkNodeSqliteCompat();
99
100 const compiler = Statement.makeCompilerSqlite(options.transformQueryNames);
101 const transformRows = options.transformResultNames
102 ? Statement.defaultTransforms(options.transformResultNames).array
103 : undefined;
104
105 const makeConnection = Effect.gen(function* () {
106 const scope = yield* Effect.scope;
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 });
117 yield* Scope.addFinalizer(
118 scope,
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),
129 );
130
131 const statementReaderCache = new WeakMap<NodeSqlite.StatementSync, boolean>();
132 const hasRows = (statement: NodeSqlite.StatementSync): boolean => {
133 const cached = statementReaderCache.get(statement);
134 if (cached !== undefined) {
135 return cached;
136 }
137 const value = statement.columns().length > 0;
138 statementReaderCache.set(statement, value);
139 return value;
140 };
141
142 const prepare = (sql: string) =>
143 Effect.try({
144 try: () => db.prepare(sql),
145 catch: (cause) =>
146 new SqlError({
147 reason: classifySqliteError(cause, {
148 message: "Failed to prepare statement",
149 operation: "prepare",
150 }),
151 }),
152 });
153
154 const prepareCache = yield* Cache.make({
155 capacity: options.prepareCacheSize ?? 200,
156 timeToLive: options.prepareCacheTTL ?? Duration.minutes(10),
157 lookup: prepare,
158 });
159
160 const runStatement = (
161 statement: NodeSqlite.StatementSync,
162 params: ReadonlyArray<unknown>,
163 raw: boolean,
164 ) =>
165 Effect.withFiber<ReadonlyArray<any>, SqlError>((fiber) => {
166 try {
167 statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers)));
168 if (hasRows(statement)) {
169 return Effect.succeed(statement.all(...(params as any)));
170 }
171 const result = statement.run(...(params as any));
172 return Effect.succeed(raw ? (result as unknown as ReadonlyArray<any>) : []);
173 } catch (cause) {
174 return Effect.fail(
175 new SqlError({
176 reason: classifySqliteError(cause, {
177 message: "Failed to execute statement",
178 operation: "execute",
179 }),
180 }),
181 );
182 }
183 });
184
185 const run = (sql: string, params: ReadonlyArray<unknown>, raw = false) =>
186 Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw));
187
188 const runStatementValues = (
189 statement: NodeSqlite.StatementSync,
190 params: ReadonlyArray<unknown>,
191 ) =>
192 Effect.acquireUseRelease(
193 Effect.succeed(statement),
194 (statement) =>
195 Effect.try({
196 try: () => {
197 if (hasRows(statement)) {
198 statement.setReturnArrays(true);
199 // Safe to cast to array after we've setReturnArrays(true)
200 return statement.all(...(params as any)) as unknown as ReadonlyArray<
201 ReadonlyArray<unknown>
202 >;
203 }
204 statement.run(...(params as any));
205 return [];
206 },
207 catch: (cause) =>
208 new SqlError({
209 reason: classifySqliteError(cause, {
210 message: "Failed to execute statement",
211 operation: "execute",
212 }),
213 }),
214 }),
215 (statement) =>
216 Effect.try({
217 try: () => {
218 if (hasRows(statement)) {
219 statement.setReturnArrays(false);
220 }
221 },
222 catch: (cause) =>
223 new SqlError({
224 reason: classifySqliteError(cause, {
225 message: "Failed to reset statement result mode",
226 operation: "resetResultMode",
227 }),
228 }),
229 }).pipe(Effect.orDie),
230 );
231
232 const runValues = (sql: string, params: ReadonlyArray<unknown>) =>
233 Effect.flatMap(Cache.get(prepareCache, sql), (statement) =>
234 runStatementValues(statement, params),
235 );
236
237 return identity<Connection>({
238 execute(sql, params, rowTransform) {
239 return rowTransform ? Effect.map(run(sql, params), rowTransform) : run(sql, params);
240 },
241 executeRaw(sql, params) {
242 return run(sql, params, true);
243 },
244 executeValues(sql, params) {
245 return runValues(sql, params);
246 },
247 executeValuesUnprepared(sql, params) {
248 return Effect.flatMap(prepare(sql), (statement) =>
249 runStatementValues(statement, params ?? []),
250 );
251 },
252 executeUnprepared(sql, params, rowTransform) {
253 const effect = prepare(sql).pipe(
254 Effect.flatMap((statement) => runStatement(statement, params ?? [], false)),
255 );
256 return rowTransform ? Effect.map(effect, rowTransform) : effect;
257 },
258 executeStream(_sql, _params) {
259 return Stream.die(new UnsupportedNodeSqliteOperationError());
260 },
261 });
262 });
263
264 const semaphore = yield* Semaphore.make(1);
265 const connection = yield* makeConnection;
266
267 const acquirer = semaphore.withPermits(1)(Effect.succeed(connection));
268 const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
269 const fiber = Fiber.getCurrent()!;
270 const scope = Context.getUnsafe(fiber.context, Scope.Scope);
271 return Effect.as(
272 Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
273 connection,
274 );
275 });
276
277 return yield* Client.make({
278 acquirer,
279 compiler,
280 transactionAcquirer,
281 spanAttributes: [
282 ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
283 [ATTR_DB_SYSTEM_NAME, "sqlite"],
284 ],
285 transformRows,
286 });
287});
288
289const make = (
290 options: SqliteClientConfig,
291): Effect.Effect<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>
292 makeWithDatabase(
293 options,
294 () =>
295 new NodeSqlite.DatabaseSync(options.filename, {
296 readOnly: options.readonly ?? false,
297 allowExtension: options.allowExtension ?? false,
298 }),
299 );
300
301const makeMemory = (
302 config: SqliteMemoryClientConfig = {},
303): Effect.Effect<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>
304 makeWithDatabase(
305 {
306 ...config,
307 filename: ":memory:",
308 readonly: false,
309 },
310 () => {
311 const database = new NodeSqlite.DatabaseSync(":memory:", {
312 allowExtension: config.allowExtension ?? false,
313 });
314 return database;
315 },
316 );
317
318export const layerConfig = (
319 config: Config.Wrap<SqliteClientConfig>,
320): Layer.Layer<Client.SqlClient, Config.ConfigError | SqlError> =>
321 Layer.effect(Client.SqlClient, Config.unwrap(config).pipe(Effect.flatMap(make))).pipe(
322 Layer.provide(Reactivity.layer),
323 );
324
325export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient, SqlError> =>
326 Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer));
327
328export const layerMemory = (
329 config: SqliteMemoryClientConfig = {},
330): Layer.Layer<Client.SqlClient, SqlError> =>
331 Layer.effect(Client.SqlClient, makeMemory(config)).pipe(Layer.provide(Reactivity.layer));
packages/shared/package.json+4
...@@ -246,6 +246,10 @@...@@ -246,6 +246,10 @@
246 "./claudeCompaction": {246 "./claudeCompaction": {
247 "types": "./src/claudeCompaction.ts",247 "types": "./src/claudeCompaction.ts",
248 "import": "./src/claudeCompaction.ts"248 "import": "./src/claudeCompaction.ts"
249 },
250 "./nodeSqliteClient": {
251 "types": "./src/nodeSqliteClient.ts",
252 "import": "./src/nodeSqliteClient.ts"
249 }253 }
250 },254 },
251 "scripts": {255 "scripts": {
packages/shared/src/nodeSqliteClient.test.ts created+56
...@@ -0,0 +1,56 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import * as SqliteClient from "./nodeSqliteClient.ts";
7
8const layer = it.layer(SqliteClient.layerMemory());
9
10layer("NodeSqliteClient", (it) => {
11 it.effect("runs prepared queries and returns positional values", () =>
12 Effect.gen(function* () {
13 const sql = yield* SqlClient.SqlClient;
14
15 yield* sql`CREATE TABLE entries(id INTEGER PRIMARY KEY, name TEXT NOT NULL)`;
16 yield* sql`INSERT INTO entries(name) VALUES (${"alpha"}), (${"beta"})`;
17
18 const rows = yield* sql<{ readonly id: number; readonly name: string }>`
19 SELECT id, name FROM entries ORDER BY id
20 `;
21 assert.equal(rows.length, 2);
22 assert.equal(rows[0]?.name, "alpha");
23 assert.equal(rows[1]?.name, "beta");
24
25 const values = yield* sql`SELECT id, name FROM entries ORDER BY id`.values;
26 assert.equal(values.length, 2);
27 assert.equal(values[0]?.[1], "alpha");
28 assert.equal(values[1]?.[1], "beta");
29
30 const unpreparedValues = yield* sql`SELECT id, name FROM entries ORDER BY id`
31 .valuesUnprepared;
32 assert.deepEqual(unpreparedValues, values);
33 }),
34 );
35
36 it.effect("returns a typed failure when an unprepared statement cannot be prepared", () =>
37 Effect.gen(function* () {
38 const sql = yield* SqlClient.SqlClient;
39 const error = yield* Effect.flip(sql.unsafe("SELECT FROM").unprepared);
40
41 assert.equal(error._tag, "SqlError");
42 assert.equal(error.reason.operation, "prepare");
43 }),
44 );
45});
46
47it.effect("returns a typed failure when the database cannot be opened", () =>
48 Effect.gen(function* () {
49 const error = yield* Effect.flip(
50 Layer.build(SqliteClient.layer({ filename: "\0" })).pipe(Effect.scoped),
51 );
52
53 assert.equal(error._tag, "SqlError");
54 assert.equal(error.reason.operation, "open");
55 }),
56);
packages/shared/src/nodeSqliteClient.ts created+331
...@@ -0,0 +1,331 @@
1/**
2 * Port of `@effect/sql-sqlite-node` that uses the native `node:sqlite`
3 * bindings instead of `better-sqlite3`.
4 *
5 * @module SqliteClient
6 */
7import * as NodeSqlite from "node:sqlite";
8
9import * as Cache from "effect/Cache";
10import * as Config from "effect/Config";
11import * as Duration from "effect/Duration";
12import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";
14import { identity } from "effect/Function";
15import * as Layer from "effect/Layer";
16import * as Schema from "effect/Schema";
17import * as Scope from "effect/Scope";
18import * as Semaphore from "effect/Semaphore";
19import * as Context from "effect/Context";
20import * as Stream from "effect/Stream";
21import * as Reactivity from "effect/unstable/reactivity/Reactivity";
22import * as Client from "effect/unstable/sql/SqlClient";
23import type { Connection } from "effect/unstable/sql/SqlConnection";
24import { SqlError, classifySqliteError } from "effect/unstable/sql/SqlError";
25import * as Statement from "effect/unstable/sql/Statement";
26
27const ATTR_DB_SYSTEM_NAME = "db.system.name";
28
29export const TypeId: TypeId = "~local/sqlite-node/SqliteClient";
30
31export type TypeId = "~local/sqlite-node/SqliteClient";
32
33export interface SqliteClientConfig {
34 readonly filename: string;
35 readonly readonly?: boolean | undefined;
36 readonly allowExtension?: boolean | undefined;
37 readonly prepareCacheSize?: number | undefined;
38 readonly prepareCacheTTL?: Duration.Input | undefined;
39 readonly spanAttributes?: Record<string, unknown> | undefined;
40 readonly transformResultNames?: ((str: string) => string) | undefined;
41 readonly transformQueryNames?: ((str: string) => string) | undefined;
42}
43
44export interface SqliteMemoryClientConfig extends Omit<
45 SqliteClientConfig,
46 "filename" | "readonly"
47> {}
48
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
70/**
71 * Verify that the current Node.js version includes the `node:sqlite` APIs
72 * used by `NodeSqliteClient` — specifically `StatementSync.columns()` (added
73 * in Node 22.16.0 / 23.11.0).
74 *
75 * @see https://github.com/nodejs/node/pull/57490
76 */
77const checkNodeSqliteCompat = () => {
78 const parts = process.versions.node.split(".").map(Number);
79 const major = parts[0] ?? 0;
80 const minor = parts[1] ?? 0;
81 const supported = (major === 22 && minor >= 16) || (major === 23 && minor >= 11) || major >= 24;
82
83 if (!supported) {
84 return Effect.die(
85 new UnsupportedNodeSqliteVersionError({
86 nodeVersion: process.versions.node,
87 requirement: "Node.js >=22.16, >=23.11, or >=24",
88 }),
89 );
90 }
91 return Effect.void;
92};
93
94const makeWithDatabase = Effect.fn("makeWithDatabase")(function* (
95 options: SqliteClientConfig,
96 openDatabase: () => NodeSqlite.DatabaseSync,
97): Effect.fn.Return<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> {
98 yield* checkNodeSqliteCompat();
99
100 const compiler = Statement.makeCompilerSqlite(options.transformQueryNames);
101 const transformRows = options.transformResultNames
102 ? Statement.defaultTransforms(options.transformResultNames).array
103 : undefined;
104
105 const makeConnection = Effect.gen(function* () {
106 const scope = yield* Effect.scope;
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 });
117 yield* Scope.addFinalizer(
118 scope,
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),
129 );
130
131 const statementReaderCache = new WeakMap<NodeSqlite.StatementSync, boolean>();
132 const hasRows = (statement: NodeSqlite.StatementSync): boolean => {
133 const cached = statementReaderCache.get(statement);
134 if (cached !== undefined) {
135 return cached;
136 }
137 const value = statement.columns().length > 0;
138 statementReaderCache.set(statement, value);
139 return value;
140 };
141
142 const prepare = (sql: string) =>
143 Effect.try({
144 try: () => db.prepare(sql),
145 catch: (cause) =>
146 new SqlError({
147 reason: classifySqliteError(cause, {
148 message: "Failed to prepare statement",
149 operation: "prepare",
150 }),
151 }),
152 });
153
154 const prepareCache = yield* Cache.make({
155 capacity: options.prepareCacheSize ?? 200,
156 timeToLive: options.prepareCacheTTL ?? Duration.minutes(10),
157 lookup: prepare,
158 });
159
160 const runStatement = (
161 statement: NodeSqlite.StatementSync,
162 params: ReadonlyArray<unknown>,
163 raw: boolean,
164 ) =>
165 Effect.withFiber<ReadonlyArray<any>, SqlError>((fiber) => {
166 try {
167 statement.setReadBigInts(Boolean(Context.get(fiber.context, Client.SafeIntegers)));
168 if (hasRows(statement)) {
169 return Effect.succeed(statement.all(...(params as any)));
170 }
171 const result = statement.run(...(params as any));
172 return Effect.succeed(raw ? (result as unknown as ReadonlyArray<any>) : []);
173 } catch (cause) {
174 return Effect.fail(
175 new SqlError({
176 reason: classifySqliteError(cause, {
177 message: "Failed to execute statement",
178 operation: "execute",
179 }),
180 }),
181 );
182 }
183 });
184
185 const run = (sql: string, params: ReadonlyArray<unknown>, raw = false) =>
186 Effect.flatMap(Cache.get(prepareCache, sql), (s) => runStatement(s, params, raw));
187
188 const runStatementValues = (
189 statement: NodeSqlite.StatementSync,
190 params: ReadonlyArray<unknown>,
191 ) =>
192 Effect.acquireUseRelease(
193 Effect.succeed(statement),
194 (statement) =>
195 Effect.try({
196 try: () => {
197 if (hasRows(statement)) {
198 statement.setReturnArrays(true);
199 // Safe to cast to array after we've setReturnArrays(true)
200 return statement.all(...(params as any)) as unknown as ReadonlyArray<
201 ReadonlyArray<unknown>
202 >;
203 }
204 statement.run(...(params as any));
205 return [];
206 },
207 catch: (cause) =>
208 new SqlError({
209 reason: classifySqliteError(cause, {
210 message: "Failed to execute statement",
211 operation: "execute",
212 }),
213 }),
214 }),
215 (statement) =>
216 Effect.try({
217 try: () => {
218 if (hasRows(statement)) {
219 statement.setReturnArrays(false);
220 }
221 },
222 catch: (cause) =>
223 new SqlError({
224 reason: classifySqliteError(cause, {
225 message: "Failed to reset statement result mode",
226 operation: "resetResultMode",
227 }),
228 }),
229 }).pipe(Effect.orDie),
230 );
231
232 const runValues = (sql: string, params: ReadonlyArray<unknown>) =>
233 Effect.flatMap(Cache.get(prepareCache, sql), (statement) =>
234 runStatementValues(statement, params),
235 );
236
237 return identity<Connection>({
238 execute(sql, params, rowTransform) {
239 return rowTransform ? Effect.map(run(sql, params), rowTransform) : run(sql, params);
240 },
241 executeRaw(sql, params) {
242 return run(sql, params, true);
243 },
244 executeValues(sql, params) {
245 return runValues(sql, params);
246 },
247 executeValuesUnprepared(sql, params) {
248 return Effect.flatMap(prepare(sql), (statement) =>
249 runStatementValues(statement, params ?? []),
250 );
251 },
252 executeUnprepared(sql, params, rowTransform) {
253 const effect = prepare(sql).pipe(
254 Effect.flatMap((statement) => runStatement(statement, params ?? [], false)),
255 );
256 return rowTransform ? Effect.map(effect, rowTransform) : effect;
257 },
258 executeStream(_sql, _params) {
259 return Stream.die(new UnsupportedNodeSqliteOperationError());
260 },
261 });
262 });
263
264 const semaphore = yield* Semaphore.make(1);
265 const connection = yield* makeConnection;
266
267 const acquirer = semaphore.withPermits(1)(Effect.succeed(connection));
268 const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
269 const fiber = Fiber.getCurrent()!;
270 const scope = Context.getUnsafe(fiber.context, Scope.Scope);
271 return Effect.as(
272 Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
273 connection,
274 );
275 });
276
277 return yield* Client.make({
278 acquirer,
279 compiler,
280 transactionAcquirer,
281 spanAttributes: [
282 ...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
283 [ATTR_DB_SYSTEM_NAME, "sqlite"],
284 ],
285 transformRows,
286 });
287});
288
289const make = (
290 options: SqliteClientConfig,
291): Effect.Effect<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>
292 makeWithDatabase(
293 options,
294 () =>
295 new NodeSqlite.DatabaseSync(options.filename, {
296 readOnly: options.readonly ?? false,
297 allowExtension: options.allowExtension ?? false,
298 }),
299 );
300
301const makeMemory = (
302 config: SqliteMemoryClientConfig = {},
303): Effect.Effect<Client.SqlClient, SqlError, Scope.Scope | Reactivity.Reactivity> =>
304 makeWithDatabase(
305 {
306 ...config,
307 filename: ":memory:",
308 readonly: false,
309 },
310 () => {
311 const database = new NodeSqlite.DatabaseSync(":memory:", {
312 allowExtension: config.allowExtension ?? false,
313 });
314 return database;
315 },
316 );
317
318export const layerConfig = (
319 config: Config.Wrap<SqliteClientConfig>,
320): Layer.Layer<Client.SqlClient, Config.ConfigError | SqlError> =>
321 Layer.effect(Client.SqlClient, Config.unwrap(config).pipe(Effect.flatMap(make))).pipe(
322 Layer.provide(Reactivity.layer),
323 );
324
325export const layer = (config: SqliteClientConfig): Layer.Layer<Client.SqlClient, SqlError> =>
326 Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer));
327
328export const layerMemory = (
329 config: SqliteMemoryClientConfig = {},
330): Layer.Layer<Client.SqlClient, SqlError> =>
331 Layer.effect(Client.SqlClient, makeMemory(config)).pipe(Layer.provide(Reactivity.layer));