| 1 | import { LibsqlClient } from "@effect/sql-libsql" |
| 2 | import { assert, describe, it, layer } from "@effect/vitest" |
| 3 | import { Effect, Exit, Layer, Option } from "effect" |
| 4 | import * as Reactivity from "effect/reactivity/Reactivity" |
| 5 | import { LibsqlContainer } from "./util.ts" |
| 6 | |
| 7 | const Migrations = Layer.effectDiscard( |
| 8 | LibsqlClient.LibsqlClient.pipe( |
| 9 | Effect.andThen((sql) => |
| 10 | Effect.acquireRelease( |
| 11 | sql`CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)`, |
| 12 | () => sql`DROP TABLE test;`.pipe(Effect.ignore) |
| 13 | ) |
| 14 | ) |
| 15 | ) |
| 16 | ) |
| 17 | |
| 18 | describe("Client", () => { |
| 19 | it.effect("exposes the active transaction service for each client", () => |
| 20 | Effect.gen(function*() { |
| 21 | const a = yield* LibsqlClient.make({ url: ":memory:" }) |
| 22 | const b = yield* LibsqlClient.make({ url: ":memory:" }) |
| 23 | const active = Effect.all([ |
| 24 | Effect.map(Effect.serviceOption(a.transactionService), Option.isSome), |
| 25 | Effect.map(Effect.serviceOption(b.transactionService), Option.isSome) |
| 26 | ]) |
| 27 | |
| 28 | assert.deepStrictEqual(yield* active, [false, false]) |
| 29 | yield* a.withTransaction(Effect.gen(function*() { |
| 30 | assert.deepStrictEqual(yield* active, [true, false]) |
| 31 | assert.deepStrictEqual(yield* b.withTransaction(active), [true, true]) |
| 32 | assert.deepStrictEqual(yield* active, [true, false]) |
| 33 | })) |
| 34 | assert.deepStrictEqual(yield* active, [false, false]) |
| 35 | }).pipe(Effect.provide(Reactivity.layer))) |
| 36 | |
| 37 | it.effect("releases completed nested savepoints", () => |
| 38 | Effect.gen(function*() { |
| 39 | const sql = yield* LibsqlClient.make({ url: ":memory:" }) |
| 40 | yield* sql`CREATE TABLE savepoint_release (value INTEGER)` |
| 41 | yield* sql.withTransaction(Effect.gen(function*() { |
| 42 | for (const rollback of [false, true]) { |
| 43 | yield* sql.withTransaction( |
| 44 | sql`INSERT INTO savepoint_release VALUES (1)`.pipe( |
| 45 | Effect.andThen(rollback ? Effect.fail("rollback") : Effect.void) |
| 46 | ) |
| 47 | ).pipe(Effect.ignore) |
| 48 | const error = yield* sql`RELEASE SAVEPOINT effect_sql_1`.unprepared.pipe(Effect.flip) |
| 49 | assert.strictEqual(error._tag, "SqlError") |
| 50 | } |
| 51 | })) |
| 52 | assert.deepStrictEqual(yield* sql`SELECT value FROM savepoint_release`, [{ value: 1 }]) |
| 53 | const error = yield* sql.withTransaction( |
| 54 | sql.withTransaction(sql`INSERT INTO savepoint_release VALUES (2)`).pipe( |
| 55 | Effect.andThen(Effect.fail("outer rollback")) |
| 56 | ) |
| 57 | ).pipe(Effect.flip) |
| 58 | assert.strictEqual(error, "outer rollback") |
| 59 | assert.deepStrictEqual(yield* sql`SELECT value FROM savepoint_release`, [{ value: 1 }]) |
| 60 | }).pipe(Effect.provide(Reactivity.layer))) |
| 61 | |
| 62 | it.effect("keeps transactions isolated between clients", () => |
| 63 | Effect.gen(function*() { |
| 64 | const a = yield* LibsqlClient.make({ url: ":memory:" }) |
| 65 | const b = yield* LibsqlClient.make({ url: ":memory:" }) |
| 66 | |
| 67 | yield* a`CREATE TABLE marker (owner TEXT NOT NULL)` |
| 68 | yield* b`CREATE TABLE marker (owner TEXT NOT NULL)` |
| 69 | yield* a`INSERT INTO marker VALUES ('A')` |
| 70 | yield* b`INSERT INTO marker VALUES ('B')` |
| 71 | |
| 72 | const rows = yield* a.withTransaction(b`SELECT owner FROM marker`) |
| 73 | assert.deepStrictEqual(rows, [{ owner: "B" }]) |
| 74 | }).pipe( |
| 75 | Effect.scoped, |
| 76 | Effect.provide(Reactivity.layer) |
| 77 | )) |
| 78 | |
| 79 | it.effect("releases transaction serialization after begin fails", () => { |
| 80 | let transactionCalls = 0 |
| 81 | const transaction = { |
| 82 | execute: () => Promise.resolve({ rows: [] }), |
| 83 | commit: () => Promise.resolve(), |
| 84 | rollback: () => Promise.resolve() |
| 85 | } |
| 86 | const liveClient = { |
| 87 | execute: () => Promise.resolve({ rows: [] }), |
| 88 | transaction: () => { |
| 89 | transactionCalls++ |
| 90 | return transactionCalls === 1 |
| 91 | ? Promise.reject(new Error("transient begin failure")) |
| 92 | : Promise.resolve(transaction) |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | return Effect.gen(function*() { |
| 97 | const client = yield* LibsqlClient.make({ liveClient: liveClient as any }) |
| 98 | const first = yield* Effect.exit(client.withTransaction(Effect.void)) |
| 99 | assert.isTrue(Exit.isFailure(first)) |
| 100 | |
| 101 | yield* Effect.forkChild(client.withTransaction(Effect.void)) |
| 102 | yield* Effect.yieldNow |
| 103 | |
| 104 | assert.strictEqual(transactionCalls, 2) |
| 105 | }).pipe( |
| 106 | Effect.scoped, |
| 107 | Effect.provide(Reactivity.layer) |
| 108 | ) |
| 109 | }) |
| 110 | |
| 111 | // Each test recreates the same tables. |
| 112 | layer(LibsqlContainer.layerClient, { timeout: "30 seconds", concurrent: false })("shared tables", (it) => { |
| 113 | it.effect("should work", () => |
| 114 | Effect.gen(function*() { |
| 115 | const sql = yield* LibsqlClient.LibsqlClient |
| 116 | let response = yield* sql`INSERT INTO test (name) VALUES ('hello')` |
| 117 | assert.deepStrictEqual(response, []) |
| 118 | response = yield* sql`SELECT * FROM test` |
| 119 | assert.deepStrictEqual(response, [{ id: 1, name: "hello" }]) |
| 120 | response = yield* sql`SELECT * FROM test` |
| 121 | assert.deepStrictEqual(yield* sql`select * from test`.values, [ |
| 122 | [1, "hello"] |
| 123 | ]) |
| 124 | }).pipe(Effect.provide(Migrations))) |
| 125 | |
| 126 | it.effect("should work with raw", () => |
| 127 | Effect.gen(function*() { |
| 128 | const sql = yield* LibsqlClient.LibsqlClient |
| 129 | let response: any |
| 130 | response = yield* sql`CREATE TABLE test2 (id INTEGER PRIMARY KEY, name TEXT)`.raw |
| 131 | yield* Effect.addFinalizer(() => sql`DROP TABLE test2;`.pipe(Effect.ignore)) |
| 132 | assert.deepStrictEqual(response.toJSON(), { |
| 133 | columnTypes: [], |
| 134 | columns: [], |
| 135 | lastInsertRowid: null, |
| 136 | rows: [], |
| 137 | rowsAffected: 0 |
| 138 | }) |
| 139 | response = yield* sql`INSERT INTO test (name) VALUES ('hello')`.raw |
| 140 | assert.deepStrictEqual(response.toJSON(), { |
| 141 | columnTypes: [], |
| 142 | columns: [], |
| 143 | lastInsertRowid: "1", |
| 144 | rows: [], |
| 145 | rowsAffected: 1 |
| 146 | }) |
| 147 | response = yield* sql`SELECT * FROM test`.raw |
| 148 | assert.deepStrictEqual(response.toJSON(), { |
| 149 | columnTypes: ["INTEGER", "TEXT"], |
| 150 | columns: ["id", "name"], |
| 151 | lastInsertRowid: null, |
| 152 | rows: [[1, "hello"]], |
| 153 | rowsAffected: 0 |
| 154 | }) |
| 155 | }).pipe(Effect.provide(Migrations))) |
| 156 | |
| 157 | it.effect("withTransaction", () => |
| 158 | Effect.gen(function*() { |
| 159 | const sql = yield* LibsqlClient.LibsqlClient |
| 160 | yield* sql.withTransaction(sql`INSERT INTO test (name) VALUES ('hello')`) |
| 161 | const rows = yield* sql`SELECT * FROM test` |
| 162 | assert.deepStrictEqual(rows, [{ id: 1, name: "hello" }]) |
| 163 | }).pipe(Effect.provide(Migrations))) |
| 164 | |
| 165 | it.effect("withTransaction rollback", () => |
| 166 | Effect.gen(function*() { |
| 167 | const sql = yield* LibsqlClient.LibsqlClient |
| 168 | yield* sql`INSERT INTO test (name) VALUES ('hello')`.pipe( |
| 169 | Effect.andThen(Effect.fail("boom")), |
| 170 | sql.withTransaction, |
| 171 | Effect.ignore |
| 172 | ) |
| 173 | const rows = yield* sql`SELECT * FROM test` |
| 174 | assert.deepStrictEqual(rows, []) |
| 175 | }).pipe(Effect.provide(Migrations))) |
| 176 | |
| 177 | it.effect("withTransaction nested", () => |
| 178 | Effect.gen(function*() { |
| 179 | const sql = yield* LibsqlClient.LibsqlClient |
| 180 | const stmt = sql`INSERT INTO test (name) VALUES ('hello')` |
| 181 | |
| 182 | yield* stmt.pipe(Effect.andThen(() => stmt.pipe(sql.withTransaction)), sql.withTransaction) |
| 183 | const rows = yield* sql<{ total_rows: number }>`select count(*) as total_rows FROM test` |
| 184 | assert.deepStrictEqual(rows.at(0)?.total_rows, 2) |
| 185 | }).pipe(Effect.provide(Migrations))) |
| 186 | |
| 187 | it.effect("withTransaction nested rollback", () => |
| 188 | Effect.gen(function*() { |
| 189 | const sql = yield* LibsqlClient.LibsqlClient |
| 190 | const stmt = sql`INSERT INTO test (name) VALUES ('hello')` |
| 191 | |
| 192 | yield* stmt.pipe( |
| 193 | Effect.andThen(() => stmt.pipe(Effect.andThen(Effect.fail("boom")), sql.withTransaction, Effect.ignore)), |
| 194 | sql.withTransaction |
| 195 | ) |
| 196 | const rows = yield* sql<{ total_rows: number }>`select count(*) as total_rows FROM test` |
| 197 | assert.deepStrictEqual(rows.at(0)?.total_rows, 1) |
| 198 | }).pipe(Effect.provide(Migrations))) |
| 199 | }) |
| 200 | }) |