1import * as Cloudflare from "alchemy/Cloudflare";
2import * as Drizzle from "alchemy/Drizzle/Postgres";
3import { eq } from "drizzle-orm";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import { RpcSerialization, RpcServer } from "effect/rpc";
7import { Hyperdrive } from "./database.ts";
8import { Todo, TodoNotFound, TodoRpcs } from "./rpc.ts";
9import { relations, Todos } from "./schema.ts";
10
11/**
12 * The RPC backend. A {@link Cloudflare.Workers.RpcWorker} that serves {@link TodoRpcs}
13 * over HTTP (JSON), backed by Drizzle talking to Neon Postgres through a
14 * Hyperdrive pool. It is bound to the frontend as a private service binding —
15 * the browser never reaches it directly; the TanStack `/rpc` route proxies to
16 * it (see `src/routes/rpc.ts`).
17 */
18export default class Backend extends Cloudflare.Workers.RpcWorker<Backend>()(
19 "Backend",
20 {
21 main: import.meta.filename,
22 workersDev: false, // no workers.dev URL; we use the service binding instead
23 schema: TodoRpcs,
24 },
25 Effect.gen(function* () {
26 const conn = yield* Cloudflare.Hyperdrive.Connect(Hyperdrive);
27 const db = yield* Drizzle.Postgres(conn.connectionString, { relations });
28
29 // DB failures are unexpected here, so we `orDie` them into defects. That
30 // keeps each handler's typed error channel aligned with its RPC schema
31 // (`never` for list/create, `TodoNotFound` for toggle/delete).
32 const handlers = TodoRpcs.toLayer({
33 listTodos: () =>
34 db
35 .select()
36 .from(Todos)
37 .orderBy(Todos.id)
38 .pipe(
39 Effect.map((rows) => rows.map((row) => new Todo(row))),
40 Effect.orDie,
41 ),
42
43 createTodo: ({ text }) =>
44 db
45 .insert(Todos)
46 .values({ text })
47 .returning()
48 .pipe(
49 Effect.map(([row]) => new Todo(row)),
50 Effect.orDie,
51 ),
52
53 toggleTodo: ({ id, done }) =>
54 db
55 .update(Todos)
56 .set({ done })
57 .where(eq(Todos.id, id))
58 .returning()
59 .pipe(
60 Effect.orDie, // this comes before flatMap so that database errors are converted to defects, while TodoNotFound remains a failure
61 Effect.flatMap(([row]) =>
62 row
63 ? Effect.succeed(new Todo(row))
64 : new TodoNotFound({ message: `Todo ${id} not found`, id }),
65 ),
66 ),
67
68 deleteTodo: ({ id }) =>
69 db
70 .delete(Todos)
71 .where(eq(Todos.id, id))
72 .returning()
73 .pipe(
74 Effect.orDie,
75 Effect.flatMap(([row]) =>
76 row
77 ? Effect.succeed(row.id)
78 : new TodoNotFound({ message: `Todo ${id} not found`, id }),
79 ),
80 ),
81 });
82
83 return RpcServer.toHttpEffect(TodoRpcs).pipe(
84 Effect.provide(Layer.mergeAll(handlers, RpcSerialization.layerJson)),
85 );
86 }).pipe(Effect.provide(Cloudflare.Hyperdrive.ConnectBinding)),
87) {}