| 1 | import * as Cloudflare from "@/Cloudflare/index.ts"; |
| 2 | import * as Planetscale from "@/Planetscale/index.ts"; |
| 3 | import * as SQL from "@/SQL/MySQL.ts"; |
| 4 | import * as Data from "effect/Data"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as HttpServerRequest from "effect/http/HttpServerRequest"; |
| 7 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 8 | import { makeLayerUsers, makeSqlRoutes, type UserRow } from "./routes.ts"; |
| 9 | |
| 10 | export const Hyperdrive = Effect.gen(function* () { |
| 11 | const database = yield* Planetscale.MySQLDatabase("SqlMySQLDb", { |
| 12 | name: "alchemy-sql-mysql", |
| 13 | region: { slug: "us-east" }, |
| 14 | clusterSize: "PS_10", |
| 15 | }); |
| 16 | // `admin` because the fixture creates its table over the wire (`/init` |
| 17 | // runs raw DDL); the default branch is `main`. |
| 18 | const password = yield* Planetscale.MySQLPassword("SqlMySQLPassword", { |
| 19 | database, |
| 20 | role: "admin", |
| 21 | }); |
| 22 | return yield* Cloudflare.Hyperdrive.Connection("SqlMySQLEdge", { |
| 23 | origin: password.origin, |
| 24 | // The tests assert read-after-write; Hyperdrive's default SELECT caching |
| 25 | // (~60s TTL) can serve a pre-insert empty result — and keep serving it |
| 26 | // across retries — so caching is disabled for correctness assertions. |
| 27 | caching: { disabled: true }, |
| 28 | }); |
| 29 | }); |
| 30 | |
| 31 | const TABLE = "alchemy_sql_users"; |
| 32 | |
| 33 | const DDL = `CREATE TABLE IF NOT EXISTS ${TABLE} ( |
| 34 | id INT PRIMARY KEY, |
| 35 | name VARCHAR(255) NOT NULL, |
| 36 | email VARCHAR(255) NOT NULL |
| 37 | )`; |
| 38 | |
| 39 | class Rollback extends Data.TaggedError("Rollback")<{}> {} |
| 40 | |
| 41 | /** |
| 42 | * Init effect for the whole route surface: binds the Hyperdrive connection, |
| 43 | * builds the `SQL.MySQL` client and the `SQL.MySQLLayer`-backed users |
| 44 | * service, and returns a handler that serves the MySQL transaction routes |
| 45 | * (`withTransaction` commit/rollback) before falling through to the shared |
| 46 | * dialect-agnostic table. |
| 47 | */ |
| 48 | const MySQLRoutes = Effect.gen(function* () { |
| 49 | const hd = yield* Cloudflare.Hyperdrive.Connect(Hyperdrive); |
| 50 | const sql = yield* SQL.MySQL({ url: hd.connectionString }); |
| 51 | const layerUsers = yield* makeLayerUsers(TABLE).pipe( |
| 52 | Effect.provide(SQL.MySQLLayer({ url: hd.connectionString })), |
| 53 | ); |
| 54 | const shared = makeSqlRoutes({ sql, layerUsers, ddl: DDL, table: TABLE }); |
| 55 | |
| 56 | const handle = Effect.fn(function* ( |
| 57 | request: HttpServerRequest.HttpServerRequest, |
| 58 | ) { |
| 59 | // POST /tx/commit — statements inside `withTransaction` share one |
| 60 | // transaction and commit together. |
| 61 | if (request.method === "POST" && request.url === "/tx/commit") { |
| 62 | const rows = (yield* request.json) as UserRow[]; |
| 63 | yield* sql.withTransaction( |
| 64 | Effect.gen(function* () { |
| 65 | for (const row of rows) { |
| 66 | yield* sql`INSERT INTO ${sql(TABLE)} ${sql.insert(row)}`; |
| 67 | } |
| 68 | }), |
| 69 | ); |
| 70 | const inserted = yield* sql` |
| 71 | SELECT id, name, email FROM ${sql(TABLE)} |
| 72 | WHERE ${sql.in( |
| 73 | "id", |
| 74 | rows.map((r) => r.id), |
| 75 | )} |
| 76 | ORDER BY id |
| 77 | `; |
| 78 | return yield* HttpServerResponse.json({ rows: inserted }); |
| 79 | } |
| 80 | |
| 81 | // POST /tx/rollback — a failure inside `withTransaction` rolls the |
| 82 | // insert back; the row must not be visible afterwards. |
| 83 | if (request.method === "POST" && request.url === "/tx/rollback") { |
| 84 | const row = (yield* request.json) as UserRow; |
| 85 | const error = yield* sql |
| 86 | .withTransaction( |
| 87 | Effect.gen(function* () { |
| 88 | yield* sql`INSERT INTO ${sql(TABLE)} ${sql.insert(row)}`; |
| 89 | return yield* Effect.fail(new Rollback()); |
| 90 | }), |
| 91 | ) |
| 92 | .pipe(Effect.flip); |
| 93 | const rows = |
| 94 | yield* sql`SELECT id FROM ${sql(TABLE)} WHERE id = ${row.id}`; |
| 95 | return yield* HttpServerResponse.json({ |
| 96 | error: (error as { _tag: string })._tag, |
| 97 | rows, |
| 98 | }); |
| 99 | } |
| 100 | |
| 101 | return yield* shared.handle(request); |
| 102 | }); |
| 103 | |
| 104 | return { handle }; |
| 105 | }); |
| 106 | |
| 107 | /** |
| 108 | * Effect-native Worker driving `SQL.MySQL` (the raw `@effect/sql-mysql2` |
| 109 | * client) over a Hyperdrive connection to a PlanetScale MySQL origin. |
| 110 | */ |
| 111 | export default class SqlMySQLWorker extends Cloudflare.Worker<SqlMySQLWorker>()( |
| 112 | "SqlMySQLWorker", |
| 113 | { |
| 114 | main: import.meta.url, |
| 115 | }, |
| 116 | Effect.gen(function* () { |
| 117 | const routes = yield* MySQLRoutes; |
| 118 | |
| 119 | return { |
| 120 | fetch: Effect.gen(function* () { |
| 121 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 122 | const response = yield* routes.handle(request); |
| 123 | if (response !== undefined) { |
| 124 | return response; |
| 125 | } |
| 126 | return yield* HttpServerResponse.json( |
| 127 | { error: "not found" }, |
| 128 | { status: 404 }, |
| 129 | ); |
| 130 | }).pipe( |
| 131 | Effect.catchCause((cause) => |
| 132 | HttpServerResponse.json({ error: String(cause) }, { status: 500 }), |
| 133 | ), |
| 134 | ), |
| 135 | }; |
| 136 | }).pipe(Effect.provide(Cloudflare.Hyperdrive.ConnectBinding)), |
| 137 | ) {} |