1import * as Drizzle from "alchemy/Drizzle/Postgres";
2import * as Fly from "alchemy/Fly";
3import { eq, sql } from "drizzle-orm";
4import * as Effect from "effect/Effect";
5import { HttpServerRequest } from "effect/http/HttpServerRequest";
6import * as HttpServerResponse from "effect/http/HttpServerResponse";
7import { relations, Users } from "./schema.ts";
8import { API_PORT, Db, MIGRATE_TOKEN } from "./shared.ts";
9
10/**
11 * HTTP Service that binds Managed Postgres via {@link Fly.ConnectPostgres}
12 * and queries through Drizzle.
13 *
14 * Also exposes `POST /migrate`: MPG is only reachable on the org's private
15 * network, so DDL is applied through this in-network Service — the deploy
16 * (or test) posts the generated migration SQL here instead of connecting
17 * to the cluster directly.
18 */
19export default class Api extends Fly.Service<Api>()(
20 "Api",
21 {
22 main: import.meta.url,
23 region: "iad",
24 port: API_PORT,
25 guest: { cpuKind: "shared", cpus: 1, memoryMb: 256 },
26 // `pg` is CommonJS — bundling it breaks `Client` under Rolldown's
27 // interop. Install it into the image so `@effect/sql-pg` /
28 // `Drizzle.Postgres` load it with Node's CJS semantics.
29 build: { install: ["pg"] },
30 },
31 Effect.gen(function* () {
32 const conn = yield* Fly.ConnectPostgres(Db);
33 const db = yield* Drizzle.Postgres(conn.connectionString, {
34 relations,
35 });
36 // DDL and other session-scoped statements need the direct
37 // (non-PgBouncer) connection.
38 const directDb = yield* Drizzle.Postgres(conn.directConnectionString);
39
40 return {
41 fetch: Effect.gen(function* () {
42 const request = yield* HttpServerRequest;
43 const path = new URL(request.url, "http://service").pathname;
44 if (path === "/migrate" && request.method === "POST") {
45 if (request.headers["x-migrate-token"] !== MIGRATE_TOKEN) {
46 return yield* HttpServerResponse.json(
47 { error: "unauthorized" },
48 { status: 401 },
49 );
50 }
51 // drizzle-kit emits one file per migration with statements
52 // separated by `--> statement-breakpoint`. Apply each statement,
53 // tolerating re-runs (the client retries through service warmup,
54 // so a statement may already have been applied).
55 const body = yield* request.text;
56 const statements = body
57 .split("--> statement-breakpoint")
58 .map((statement) => statement.trim())
59 .filter((statement) => statement.length > 0);
60 for (const statement of statements) {
61 yield* directDb.execute(sql.raw(statement)).pipe(
62 Effect.catch((error) =>
63 String(error).includes("already exists")
64 ? Effect.void
65 : Effect.fail(error),
66 ),
67 );
68 }
69 return yield* HttpServerResponse.json({
70 applied: statements.length,
71 });
72 }
73 if (path === "/health") {
74 const users = yield* db.select().from(Users);
75 return yield* HttpServerResponse.json({
76 ok: true,
77 users: users.length,
78 });
79 }
80 switch (request.method) {
81 case "GET": {
82 if (path === "/" || path === "/users") {
83 const users = yield* db.select().from(Users);
84 return yield* HttpServerResponse.json({ users });
85 }
86 const id = Number(path.split("/").pop());
87 if (Number.isNaN(id)) {
88 return yield* HttpServerResponse.json(
89 { error: "Invalid user ID" },
90 { status: 400 },
91 );
92 }
93 const user = yield* db.query.Users.findFirst({
94 where: { id },
95 with: { posts: true },
96 });
97 return yield* HttpServerResponse.json({ user });
98 }
99 case "POST": {
100 const user = yield* db
101 .insert(Users)
102 .values({
103 name: crypto.randomUUID(),
104 email: crypto.randomUUID(),
105 })
106 .returning();
107 return yield* HttpServerResponse.json({ user });
108 }
109 case "DELETE": {
110 const id = Number(path.split("/").pop());
111 if (Number.isNaN(id)) {
112 return yield* HttpServerResponse.json(
113 { error: "Invalid user ID" },
114 { status: 400 },
115 );
116 }
117 const [user] = yield* db
118 .delete(Users)
119 .where(eq(Users.id, id))
120 .returning();
121 return yield* HttpServerResponse.json({ user });
122 }
123 default: {
124 return yield* HttpServerResponse.json(
125 { error: "Method not allowed" },
126 { status: 405 },
127 );
128 }
129 }
130 }).pipe(
131 Effect.catch((cause: unknown) =>
132 HttpServerResponse.json(
133 { ok: false, error: String(cause) },
134 { status: 500 },
135 ),
136 ),
137 ),
138 };
139 }).pipe(Effect.provide(Fly.ConnectPostgresHttp)),
140) {}