| 1 | import * as Cloudflare from "@/Cloudflare/index.ts"; |
| 2 | import * as Neon from "@/Neon/index.ts"; |
| 3 | import * as SQL from "@/SQL/Postgres.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 project = yield* Neon.Project("SqlPostgresProject"); |
| 12 | return yield* Cloudflare.Hyperdrive.Connection("SqlPostgresEdge", { |
| 13 | origin: project.origin, |
| 14 | // The tests assert read-after-write; Hyperdrive's default SELECT caching |
| 15 | // (~60s TTL) can serve a pre-insert empty result — and keep serving it |
| 16 | // across retries — so caching is disabled for correctness assertions. |
| 17 | caching: { disabled: true }, |
| 18 | }); |
| 19 | }); |
| 20 | |
| 21 | const TABLE = "alchemy_sql_users"; |
| 22 | |
| 23 | const DDL = `CREATE TABLE IF NOT EXISTS ${TABLE} ( |
| 24 | id INT PRIMARY KEY, |
| 25 | name TEXT NOT NULL, |
| 26 | email TEXT NOT NULL |
| 27 | )`; |
| 28 | |
| 29 | class Rollback extends Data.TaggedError("Rollback")<{}> {} |
| 30 | |
| 31 | /** |
| 32 | * Init effect for the whole route surface: binds the Hyperdrive connection, |
| 33 | * builds the `SQL.Postgres` client and the `SQL.PostgresLayer`-backed users |
| 34 | * service, and returns a handler that serves the Postgres-only routes |
| 35 | * (`withTransaction` commit/rollback, multi-row `sql.updateValues`) before |
| 36 | * falling through to the shared dialect-agnostic table. |
| 37 | */ |
| 38 | const PostgresRoutes = Effect.gen(function* () { |
| 39 | const hd = yield* Cloudflare.Hyperdrive.Connect(Hyperdrive); |
| 40 | const sql = yield* SQL.Postgres({ url: hd.connectionString }); |
| 41 | const layerUsers = yield* makeLayerUsers(TABLE).pipe( |
| 42 | Effect.provide(SQL.PostgresLayer({ url: hd.connectionString })), |
| 43 | ); |
| 44 | const shared = makeSqlRoutes({ sql, layerUsers, ddl: DDL, table: TABLE }); |
| 45 | |
| 46 | const handle = Effect.fn(function* ( |
| 47 | request: HttpServerRequest.HttpServerRequest, |
| 48 | ) { |
| 49 | // POST /tx/commit — statements inside `withTransaction` share one |
| 50 | // transaction and commit together. |
| 51 | if (request.method === "POST" && request.url === "/tx/commit") { |
| 52 | const rows = (yield* request.json) as UserRow[]; |
| 53 | yield* sql.withTransaction( |
| 54 | Effect.gen(function* () { |
| 55 | for (const row of rows) { |
| 56 | yield* sql`INSERT INTO ${sql(TABLE)} ${sql.insert(row)}`; |
| 57 | } |
| 58 | }), |
| 59 | ); |
| 60 | const inserted = yield* sql` |
| 61 | SELECT id, name, email FROM ${sql(TABLE)} |
| 62 | WHERE ${sql.in( |
| 63 | "id", |
| 64 | rows.map((r) => r.id), |
| 65 | )} |
| 66 | ORDER BY id |
| 67 | `; |
| 68 | return yield* HttpServerResponse.json({ rows: inserted }); |
| 69 | } |
| 70 | |
| 71 | // POST /tx/rollback — a failure inside `withTransaction` rolls the |
| 72 | // insert back; the row must not be visible afterwards. |
| 73 | if (request.method === "POST" && request.url === "/tx/rollback") { |
| 74 | const row = (yield* request.json) as UserRow; |
| 75 | const error = yield* sql |
| 76 | .withTransaction( |
| 77 | Effect.gen(function* () { |
| 78 | yield* sql`INSERT INTO ${sql(TABLE)} ${sql.insert(row)}`; |
| 79 | return yield* Effect.fail(new Rollback()); |
| 80 | }), |
| 81 | ) |
| 82 | .pipe(Effect.flip); |
| 83 | const rows = |
| 84 | yield* sql`SELECT id FROM ${sql(TABLE)} WHERE id = ${row.id}`; |
| 85 | return yield* HttpServerResponse.json({ |
| 86 | error: (error as { _tag: string })._tag, |
| 87 | rows, |
| 88 | }); |
| 89 | } |
| 90 | |
| 91 | // PUT /users/values — multi-row `sql.updateValues` (not supported on |
| 92 | // sqlite/D1, so it lives here rather than in the shared routes). |
| 93 | if (request.method === "PUT" && request.url === "/users/values") { |
| 94 | const rows = (yield* request.json) as UserRow[]; |
| 95 | // `updateValues` binds every value as text — cast the join key back |
| 96 | // to int so `integer = text` doesn't reject the statement. |
| 97 | yield* sql` |
| 98 | UPDATE ${sql(TABLE)} AS t |
| 99 | SET name = data.name, email = data.email |
| 100 | FROM ${sql.updateValues(rows, "data")} |
| 101 | WHERE t.id = data.id::int |
| 102 | `; |
| 103 | const updated = yield* sql` |
| 104 | SELECT id, name, email FROM ${sql(TABLE)} |
| 105 | WHERE ${sql.in( |
| 106 | "id", |
| 107 | rows.map((r) => r.id), |
| 108 | )} |
| 109 | ORDER BY id |
| 110 | `; |
| 111 | return yield* HttpServerResponse.json({ rows: updated }); |
| 112 | } |
| 113 | |
| 114 | return yield* shared.handle(request); |
| 115 | }); |
| 116 | |
| 117 | return { handle }; |
| 118 | }); |
| 119 | |
| 120 | /** |
| 121 | * Effect-native Worker driving `SQL.Postgres` (the raw `@effect/sql-pg` |
| 122 | * client) over a Hyperdrive connection to a Neon Postgres origin. |
| 123 | */ |
| 124 | export default class SqlPostgresWorker extends Cloudflare.Worker<SqlPostgresWorker>()( |
| 125 | "SqlPostgresWorker", |
| 126 | { |
| 127 | main: import.meta.url, |
| 128 | }, |
| 129 | Effect.gen(function* () { |
| 130 | const routes = yield* PostgresRoutes; |
| 131 | |
| 132 | return { |
| 133 | fetch: Effect.gen(function* () { |
| 134 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 135 | const response = yield* routes.handle(request); |
| 136 | if (response !== undefined) { |
| 137 | return response; |
| 138 | } |
| 139 | return yield* HttpServerResponse.json( |
| 140 | { error: "not found" }, |
| 141 | { status: 404 }, |
| 142 | ); |
| 143 | }).pipe( |
| 144 | Effect.catchCause((cause) => |
| 145 | HttpServerResponse.json({ error: String(cause) }, { status: 500 }), |
| 146 | ), |
| 147 | ), |
| 148 | }; |
| 149 | }).pipe(Effect.provide(Cloudflare.Hyperdrive.ConnectBinding)), |
| 150 | ) {} |