| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import { Cause, Context, Effect, Exit, Option, Scope } from "effect" |
| 3 | import { SqlClient, SqlError } from "effect/sql" |
| 4 | |
| 5 | interface StubConnection { |
| 6 | readonly id: string |
| 7 | } |
| 8 | |
| 9 | interface StubTransactionConnection { |
| 10 | readonly _: unique symbol |
| 11 | } |
| 12 | |
| 13 | const sqlError = (message: string) => |
| 14 | new SqlError.SqlError({ |
| 15 | reason: new SqlError.UnknownError({ cause: new Error(message), message }) |
| 16 | }) |
| 17 | |
| 18 | let 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 | */ |
| 31 | const 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 | |
| 109 | const 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 | |
| 121 | describe("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 | }) |