From 4002b8570766b24f94b4a76282174fca5fb4576d Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 2 Jun 2026 14:36:48 -0400 Subject: [PATCH] fix(effect-drizzle-sqlite): preserve transaction begin errors (#30448) --- .../src/effect-sqlite/session.ts | 60 ++++++++++--------- .../effect-drizzle-sqlite/test/sqlite.test.ts | 33 ++++++++++ 2 files changed, 65 insertions(+), 28 deletions(-) diff --git a/packages/effect-drizzle-sqlite/src/effect-sqlite/session.ts b/packages/effect-drizzle-sqlite/src/effect-sqlite/session.ts index 047b50b61..cba4c11bd 100644 --- a/packages/effect-drizzle-sqlite/src/effect-sqlite/session.ts +++ b/packages/effect-drizzle-sqlite/src/effect-sqlite/session.ts @@ -139,8 +139,8 @@ export class EffectSQLiteSession extends SQLite const id = connectionOption._tag === "Some" ? connectionOption.value[1] + 1 : 0 return connection.pipe( - Effect.flatMap(([scope, connection]) => - this.executeTransactionStatement( + Effect.flatMap(([scope, connection]) => { + const transaction = this.executeTransactionStatement( connection, id === 0 ? `begin ${config?.behavior ?? "deferred"}` : `savepoint effect_sql_${id}`, ).pipe( @@ -148,35 +148,39 @@ export class EffectSQLiteSession extends SQLite Effect.provideContext( restore(effect), Context.add(services, this.client.transactionService, [connection, id]), + ).pipe( + Effect.exit, + Effect.flatMap((exit) => { + const finalize = Exit.isSuccess(exit) + ? id === 0 + ? this.executeTransactionStatement(connection, "commit").pipe( + // SQLite keeps the transaction open after deferred constraint commit failures. + Effect.catch((error) => + this.executeTransactionStatement(connection, "rollback").pipe( + Effect.catch(() => Effect.void), + Effect.andThen(Effect.fail(error)), + ), + ), + ) + : this.executeTransactionStatement(connection, `release savepoint effect_sql_${id}`) + : id === 0 + ? this.executeTransactionStatement(connection, "rollback") + : this.executeTransactionStatement(connection, `rollback to savepoint effect_sql_${id}`).pipe( + Effect.andThen( + this.executeTransactionStatement(connection, `release savepoint effect_sql_${id}`), + ), + ) + + return finalize.pipe(Effect.flatMap(() => exit)) + }), ), ), - Effect.exit, - Effect.flatMap((exit) => { - const finalize = Exit.isSuccess(exit) - ? id === 0 - ? this.executeTransactionStatement(connection, "commit").pipe( - // SQLite keeps the transaction open after deferred constraint commit failures. - Effect.catch((error) => - this.executeTransactionStatement(connection, "rollback").pipe( - Effect.catch(() => Effect.void), - Effect.andThen(Effect.fail(error)), - ), - ), - ) - : this.executeTransactionStatement(connection, `release savepoint effect_sql_${id}`) - : id === 0 - ? this.executeTransactionStatement(connection, "rollback") - : this.executeTransactionStatement(connection, `rollback to savepoint effect_sql_${id}`).pipe( - Effect.andThen( - this.executeTransactionStatement(connection, `release savepoint effect_sql_${id}`), - ), - ) - const scoped = scope === undefined ? finalize : Effect.ensuring(finalize, Scope.close(scope, exit)) + ) - return scoped.pipe(Effect.flatMap(() => exit)) - }), - ), - ), + return scope === undefined + ? transaction + : transaction.pipe(Effect.onExit((exit) => Scope.close(scope, exit))) + }), ) }), ) diff --git a/packages/effect-drizzle-sqlite/test/sqlite.test.ts b/packages/effect-drizzle-sqlite/test/sqlite.test.ts index 69e6ebed2..5303ee069 100644 --- a/packages/effect-drizzle-sqlite/test/sqlite.test.ts +++ b/packages/effect-drizzle-sqlite/test/sqlite.test.ts @@ -1,12 +1,14 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" +import { Database } from "bun:sqlite" import { expect, test } from "bun:test" import { SqliteClient } from "@effect/sql-sqlite-bun" import { eq, sql } from "drizzle-orm" import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core" import { Effect } from "effect" import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" +import { isSqlError } from "effect/unstable/sql/SqlError" import { EffectDrizzleSqlite } from "../src" const users = sqliteTable("users", { @@ -97,6 +99,37 @@ test("rolls back explicit transaction rollback", async () => { ) }) +test("preserves failed transaction begin errors", async () => { + const dir = await mkdtemp(join(tmpdir(), "effect-drizzle-sqlite-")) + const filename = join(dir, "locked.db") + const holder = new Database(filename) + + try { + holder.run("create table users (id integer primary key autoincrement, name text not null)") + holder.run("pragma busy_timeout = 0") + holder.run("begin immediate") + + await Effect.runPromise( + Effect.gen(function* () { + const db = yield* EffectDrizzleSqlite.makeWithDefaults() + yield* db.run(sql`pragma busy_timeout = 0`) + + const error = yield* db + .transaction((tx) => tx.insert(users).values({ name: "Blocked" }), { behavior: "immediate" }) + .pipe(Effect.flip) + + if (!isSqlError(error)) throw new Error("Expected SqlError") + expect(error.reason._tag).toBe("LockTimeoutError") + expect(error.reason.cause instanceof Error ? error.reason.cause.message : "").toContain("database is locked") + }).pipe(Effect.provide(SqliteClient.layer({ filename, disableWAL: true })), Effect.scoped), + ) + } finally { + if (holder.inTransaction) holder.run("rollback") + holder.close() + await rm(dir, { recursive: true, force: true }) + } +}) + test("supports returning and rejects empty update sets", async () => { await run( Effect.gen(function* () {