1import { assert, describe, it } from "@effect/vitest"
2import { Cause, Context, Effect, Exit, Option, Scope } from "effect"
3import { SqlClient, SqlError } from "effect/sql"
4
5interface StubConnection {
6 readonly id: string
7}
8
9interface StubTransactionConnection {
10 readonly _: unique symbol
11}
12
13const sqlError = (message: string) =>
14 new SqlError.SqlError({
15 reason: new SqlError.UnknownError({ cause: new Error(message), message })
16 })
17
18let harnessIdCounter = 0
19
20/**
21 * Builds a `makeWithTransaction` wrapper over stub transaction commands, so the
22 * transaction control flow can be driven without a database driver.
23 *
24 * The stubs mirror the driver contract: `rollback` and `rollbackSavepoint` fail
25 * when no transaction or savepoint is active, which is what a real database
26 * reports when a rollback is issued after a failed `BEGIN`.
27 *
28 * Savepoint release is opt-in, as it is for drivers: pass `true` to enable it,
29 * or a `SqlError` to enable it and have it fail.
30 */
31const makeHarness = (options: {
32 readonly begin?: SqlError.SqlError | undefined
33 readonly savepoint?: SqlError.SqlError | undefined
34 readonly rollbackSavepoint?: SqlError.SqlError | undefined
35 readonly releaseSavepoint?: true | SqlError.SqlError | undefined
36} = {}) => {
37 const release = options.releaseSavepoint
38 const calls: Array<string> = []
39 const conn: StubConnection = { id: "stub" }
40 const transactionService = Context.Service<
41 StubTransactionConnection,
42 readonly [conn: StubConnection, counter: number]
43 >(`test/SqlClient/TransactionConnection/${harnessIdCounter++}`)
44
45 let transactionActive = false
46 const savepoints: Array<number> = []
47
48 const record = (name: string) => Effect.sync(() => calls.push(name))
49
50 const withTransaction = SqlClient.makeWithTransaction({
51 transactionService,
52 spanAttributes: [],
53 acquireConnection: Effect.flatMap(Scope.make(), (scope) =>
54 Effect.as(
55 Effect.flatMap(
56 record("acquireConnection"),
57 () => Scope.addFinalizer(scope, record("closeConnection"))
58 ),
59 [scope, conn] as const
60 )),
61 begin: () =>
62 Effect.flatMap(record("begin"), () => {
63 if (options.begin !== undefined) {
64 return Effect.fail(options.begin)
65 }
66 transactionActive = true
67 return Effect.void
68 }),
69 savepoint: (_conn, id) =>
70 Effect.flatMap(record(`savepoint(${id})`), () => {
71 if (options.savepoint !== undefined) {
72 return Effect.fail(options.savepoint)
73 }
74 savepoints.push(id)
75 return Effect.void
76 }),
77 commit: () =>
78 Effect.flatMap(record("commit"), () =>
79 transactionActive
80 ? Effect.sync(() => {
81 transactionActive = false
82 })
83 : Effect.fail(sqlError("cannot commit - no transaction is active"))),
84 rollback: () =>
85 Effect.flatMap(record("rollback"), () =>
86 transactionActive
87 ? Effect.sync(() => {
88 transactionActive = false
89 })
90 : Effect.fail(sqlError("cannot rollback - no transaction is active"))),
91 rollbackSavepoint: (_conn, id) =>
92 Effect.flatMap(record(`rollbackSavepoint(${id})`), () => {
93 if (options.rollbackSavepoint !== undefined) {
94 return Effect.fail(options.rollbackSavepoint)
95 }
96 return savepoints.includes(id)
97 ? Effect.void
98 : Effect.fail(sqlError(`cannot rollback to savepoint ${id} - it does not exist`))
99 }),
100 releaseSavepoint: release === undefined ?
101 undefined :
102 (_conn, id) =>
103 Effect.flatMap(record(`releaseSavepoint(${id})`), () => release === true ? Effect.void : Effect.fail(release))
104 })
105
106 return { calls, withTransaction } as const
107}
108
109const assertTypedFailure = <A, E>(exit: Exit.Exit<A, E>, error: E) => {
110 assert.isTrue(Exit.isFailure(exit))
111 if (!Exit.isFailure(exit)) {
112 return
113 }
114 assert.isFalse(
115 Cause.hasDies(exit.cause),
116 `expected a typed failure but the cause contains a defect:\n${Cause.pretty(exit.cause)}`
117 )
118 assert.deepStrictEqual(Cause.findErrorOption(exit.cause), Option.some(error))
119}
120
121describe("SqlClient", () => {
122 describe("makeWithTransaction", () => {
123 it.effect("propagates a failed begin as a typed error without rolling back", () =>
124 Effect.gen(function*() {
125 const beginError = sqlError("database is locked")
126 const harness = makeHarness({ begin: beginError })
127 let executed = false
128
129 const exit = yield* Effect.exit(harness.withTransaction(Effect.sync(() => {
130 executed = true
131 })))
132
133 assertTypedFailure(exit, beginError)
134 assert.isFalse(executed)
135 assert.deepStrictEqual(harness.calls, ["acquireConnection", "begin", "closeConnection"])
136 }))
137
138 it.effect("rolls back when the wrapped effect fails after a successful begin", () =>
139 Effect.gen(function*() {
140 const harness = makeHarness()
141
142 const exit = yield* Effect.exit(harness.withTransaction(Effect.fail("boom" as const)))
143
144 assertTypedFailure(exit, "boom" as const)
145 assert.deepStrictEqual(harness.calls, ["acquireConnection", "begin", "rollback", "closeConnection"])
146 }))
147
148 it.effect("propagates a failed savepoint as a typed error without rolling back", () =>
149 Effect.gen(function*() {
150 const savepointError = sqlError("cannot create savepoint")
151 const harness = makeHarness({ savepoint: savepointError })
152 let executed = false
153
154 const exit = yield* Effect.exit(harness.withTransaction(
155 harness.withTransaction(Effect.sync(() => {
156 executed = true
157 }))
158 ))
159
160 assertTypedFailure(exit, savepointError)
161 assert.isFalse(executed)
162 assert.deepStrictEqual(harness.calls, [
163 "acquireConnection",
164 "begin",
165 "savepoint(1)",
166 "rollback",
167 "closeConnection"
168 ])
169 }))
170
171 it.effect("releases savepoints after nested transactions succeed or roll back", () =>
172 Effect.gen(function*() {
173 const harness = makeHarness({ releaseSavepoint: true })
174
175 const exit = yield* harness.withTransaction(Effect.gen(function*() {
176 yield* harness.withTransaction(Effect.void)
177 return yield* Effect.exit(harness.withTransaction(Effect.fail("boom" as const)))
178 }))
179
180 assertTypedFailure(exit, "boom" as const)
181 assert.deepStrictEqual(harness.calls, [
182 "acquireConnection",
183 "begin",
184 "savepoint(1)",
185 "releaseSavepoint(1)",
186 "savepoint(1)",
187 "rollbackSavepoint(1)",
188 "releaseSavepoint(1)",
189 "commit",
190 "closeConnection"
191 ])
192 }))
193
194 it.effect("does not release savepoints when the driver does not opt in", () =>
195 Effect.gen(function*() {
196 const harness = makeHarness()
197
198 const result = yield* harness.withTransaction(harness.withTransaction(Effect.succeed(1)))
199
200 assert.strictEqual(result, 1)
201 assert.deepStrictEqual(harness.calls, [
202 "acquireConnection",
203 "begin",
204 "savepoint(1)",
205 "commit",
206 "closeConnection"
207 ])
208 }))
209
210 it.effect("skips savepoint release when rollback fails", () =>
211 Effect.gen(function*() {
212 const rollbackError = sqlError("cannot roll back savepoint")
213 const harness = makeHarness({ releaseSavepoint: true, rollbackSavepoint: rollbackError })
214
215 const exit = yield* Effect.exit(harness.withTransaction(harness.withTransaction(Effect.fail("boom"))))
216
217 assert.deepStrictEqual(exit, Exit.failCause(Cause.combine(Cause.fail("boom"), Cause.die(rollbackError))))
218 assert.deepStrictEqual(harness.calls, [
219 "acquireConnection",
220 "begin",
221 "savepoint(1)",
222 "rollbackSavepoint(1)",
223 "rollback",
224 "closeConnection"
225 ])
226 }))
227
228 it.effect("rolls back the outer transaction when a savepoint release fails", () =>
229 Effect.gen(function*() {
230 const releaseError = sqlError("cannot release savepoint")
231 const harness = makeHarness({ releaseSavepoint: releaseError })
232
233 const exit = yield* Effect.exit(harness.withTransaction(harness.withTransaction(Effect.void)))
234
235 assert.deepStrictEqual(exit, Exit.die(releaseError))
236 assert.deepStrictEqual(harness.calls, [
237 "acquireConnection",
238 "begin",
239 "savepoint(1)",
240 "releaseSavepoint(1)",
241 "rollback",
242 "closeConnection"
243 ])
244 }))
245
246 it.effect("commits and returns the value when the wrapped effect succeeds", () =>
247 Effect.gen(function*() {
248 const harness = makeHarness()
249
250 const result = yield* harness.withTransaction(Effect.succeed(1))
251
252 assert.strictEqual(result, 1)
253 assert.deepStrictEqual(harness.calls, ["acquireConnection", "begin", "commit", "closeConnection"])
254 }))
255 })
256})