1import * as Drizzle from "alchemy/Drizzle/Postgres";
2import * as Hetzner from "alchemy/Hetzner";
3import { desc, sql } from "drizzle-orm";
4import * as Config from "effect/Config";
5import * as Effect from "effect/Effect";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import { ENSURE_NOTES_SQL, Notes } from "./schema.ts";
9import { API_PORT, Box, NeonBranch } from "./shared.ts";
10
11const cors = {
12 "access-control-allow-origin": "*",
13 "access-control-allow-methods": "GET,POST,OPTIONS",
14 "access-control-allow-headers": "content-type",
15};
16
17const json = (value: unknown, status = 200) =>
18 HttpServerResponse.json(value, { status, headers: cors });
19
20/**
21 * Effect HTTP API on the shared Hetzner Server. Reads `DATABASE_URL`
22 * (the Neon branch URI) and serves `/notes` for the Vite SPA.
23 */
24export default class Api extends Hetzner.Service<Api>()(
25 "Api",
26 Effect.gen(function* () {
27 const server = yield* Box;
28 const branch = yield* NeonBranch;
29 return {
30 server,
31 main: import.meta.url,
32 port: API_PORT,
33 env: {
34 DATABASE_URL: branch.connectionUri,
35 },
36 };
37 }),
38 Effect.succeed({
39 fetch: Effect.gen(function* () {
40 const databaseUrl = yield* Config.Redacted("DATABASE_URL");
41 const db = yield* Drizzle.Postgres(Effect.succeed(databaseUrl));
42 const request = yield* HttpServerRequest;
43 const path = new URL(request.url, "http://service").pathname;
44 if (request.method === "OPTIONS") {
45 return yield* json({}, 204);
46 }
47 yield* db.execute(sql.raw(ENSURE_NOTES_SQL));
48 if (path === "/health") {
49 return yield* json({ ok: true });
50 }
51 if (path === "/notes" && request.method === "GET") {
52 const notes = yield* db
53 .select()
54 .from(Notes)
55 .orderBy(desc(Notes.createdAt));
56 return yield* json({ notes });
57 }
58 if (path === "/notes" && request.method === "POST") {
59 const raw = yield* request.text.pipe(Effect.orDie);
60 const payload = JSON.parse(raw) as { body?: unknown };
61 const body =
62 typeof payload.body === "string" ? payload.body.trim() : "";
63 if (body.length === 0) {
64 return yield* json({ error: "body required" }, 400);
65 }
66 const [note] = yield* db.insert(Notes).values({ body }).returning();
67 return yield* json({ note }, 201);
68 }
69 return yield* json({ error: "not found" }, 404);
70 }).pipe(
71 Effect.catch((cause: unknown) =>
72 json({ ok: false, error: String(cause) }, 500),
73 ),
74 Effect.orDie,
75 ),
76 }),
77) {}