1import * as GCP from "alchemy/GCP";
2import * as Effect from "effect/Effect";
3import { HttpServerRequest } from "effect/http/HttpServerRequest";
4import * as HttpServerResponse from "effect/http/HttpServerResponse";
5import { NotesDb } from "./resources.ts";
6
7interface Note {
8 id: string;
9 title: string;
10 body: string;
11 createdAt: string;
12}
13
14const toNote = (snapshot: {
15 name: string;
16 fields: Record<string, unknown>;
17}): Note => ({
18 id: snapshot.name.split("/").pop()!,
19 title: String(snapshot.fields.title ?? ""),
20 body: String(snapshot.fields.body ?? ""),
21 createdAt:
22 snapshot.fields.createdAt instanceof Date
23 ? snapshot.fields.createdAt.toISOString()
24 : String(snapshot.fields.createdAt ?? ""),
25});
26
27const notFound = () =>
28 HttpServerResponse.json({ error: "not found" }, { status: 404 });
29
30/**
31 * A notes API on a 2nd-gen Cloud Function.
32 *
33 * Alchemy bundles this module for Node.js 22, uploads it, and serves
34 * `fetch` through the Functions Framework — no Dockerfile and no local
35 * image build. `GCP.Firestore.ReadWriteDatabase` grants the function's
36 * runtime service account `roles/datastore.user` and hands back a client
37 * that speaks plain JavaScript objects.
38 *
39 * - `POST /notes` — create a note from `{ title, body }`.
40 * - `GET /notes` — list notes.
41 * - `GET /notes/:id` — read one note.
42 * - `DELETE /notes/:id` — delete a note.
43 */
44export default class Notes extends GCP.CloudFunctions.Function<Notes>()(
45 "Notes",
46 { main: import.meta.url },
47 Effect.gen(function* () {
48 const db = yield* GCP.Firestore.ReadWriteDatabase(NotesDb);
49
50 return {
51 fetch: Effect.gen(function* () {
52 const request = yield* HttpServerRequest;
53 const { pathname } = new URL(request.url, "http://localhost");
54 const [collection, id, ...rest] = pathname.split("/").filter(Boolean);
55
56 if (collection !== "notes" || rest.length > 0) return yield* notFound();
57
58 if (id === undefined && request.method === "POST") {
59 const input = (yield* request.json) as {
60 title?: unknown;
61 body?: unknown;
62 };
63 if (typeof input.title !== "string" || input.title === "") {
64 return yield* HttpServerResponse.json(
65 { error: "title is required" },
66 { status: 400 },
67 );
68 }
69 const snapshot = yield* db.create(`notes/${crypto.randomUUID()}`, {
70 title: input.title,
71 body: typeof input.body === "string" ? input.body : "",
72 createdAt: new Date(),
73 });
74 return yield* HttpServerResponse.json(toNote(snapshot), {
75 status: 201,
76 });
77 }
78
79 if (id === undefined && request.method === "GET") {
80 const { documents } = yield* db.list("notes", { pageSize: 100 });
81 return yield* HttpServerResponse.json({
82 notes: documents.map(toNote),
83 });
84 }
85
86 if (id !== undefined && request.method === "GET") {
87 const snapshot = yield* db.get(`notes/${id}`);
88 if (snapshot === undefined) return yield* notFound();
89 return yield* HttpServerResponse.json(toNote(snapshot));
90 }
91
92 if (id !== undefined && request.method === "DELETE") {
93 yield* db.delete(`notes/${id}`);
94 return HttpServerResponse.empty({ status: 204 });
95 }
96
97 return yield* HttpServerResponse.json(
98 { error: "method not allowed" },
99 { status: 405 },
100 );
101 }).pipe(
102 // Surface Firestore failures (e.g. IAM still propagating right after
103 // a deploy) as a 500 with the error tag instead of an opaque crash.
104 Effect.catch((error) =>
105 HttpServerResponse.json({ error: error._tag }, { status: 500 }).pipe(
106 Effect.orDie,
107 ),
108 ),
109 ),
110 };
111 }).pipe(Effect.provide(GCP.Firestore.ReadWriteDatabaseHttp)),
112) {}