1import * as Alchemy from "alchemy";
2import * as Cloudflare from "alchemy/Cloudflare";
3import * as Drizzle from "alchemy/Drizzle";
4import * as Neon from "alchemy/Neon";
5import * as Test from "alchemy/Test/Bun";
6import { describe, expect } from "bun:test";
7import * as Effect from "effect/Effect";
8import * as Layer from "effect/Layer";
9import * as Schedule from "effect/Schedule";
10import * as FetchHttpClient from "effect/http/FetchHttpClient";
11import * as HttpClient from "effect/http/HttpClient";
12import * as HttpClientResponse from "effect/http/HttpClientResponse";
13import { RpcClient, RpcSerialization } from "effect/rpc";
14import Stack from "../alchemy.run.ts";
15import { TodoRpcs } from "../src/backend/rpc.ts";
16
17describe.each([true, false])(
18 "CloudflareTanstackRpcDrizzleExample (dev: %p)",
19 (dev) => {
20 const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
21 providers: Layer.mergeAll(
22 Cloudflare.providers(),
23 Drizzle.providers(),
24 Neon.providers(),
25 ),
26 state: Alchemy.localState(),
27 dev,
28 });
29
30 const stack = beforeAll(
31 deploy(Stack).pipe(
32 Effect.tap((stack) =>
33 // Fresh workers.dev URLs take a few seconds to start answering, so
34 // fetch with retries until it's ready before we exercise it in tests.
35 // Note that the response here will be the RPC server complaining that the
36 // request is invalid since we're not actually making an RPC call here.
37 // `filterStatusOk` works because Effect RPC still returns 200, but we may
38 // need to update this if/when Effect RPC changes its behavior.
39 HttpClient.get(new URL("/rpc", stack.websiteUrl)).pipe(
40 Effect.flatMap(HttpClientResponse.filterStatusOk),
41 Effect.retry({
42 schedule: Schedule.max([
43 Schedule.spaced("500 millis"),
44 Schedule.recurs(20),
45 ]),
46 }),
47 ),
48 ),
49 ),
50 );
51 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
52
53 const RpcProtocol = stack.pipe(
54 Effect.map(({ websiteUrl }) =>
55 RpcClient.layerProtocolHttp({
56 url: new URL("/rpc", websiteUrl).toString(),
57 }),
58 ),
59 Layer.unwrap,
60 Layer.provide(FetchHttpClient.layer),
61 Layer.provide(RpcSerialization.layerJson),
62 );
63
64 test(
65 "deploys and exposes urls + db identifiers",
66 Effect.gen(function* () {
67 const { websiteUrl, branchId, hyperdriveId } = yield* stack;
68 expect(websiteUrl).toBeString();
69 expect(branchId).toBeString();
70 expect(hyperdriveId).toBeString();
71 }),
72 );
73
74 test(
75 "todo CRUD round-trips through the /rpc proxy into Drizzle/Neon",
76 Effect.gen(function* () {
77 const client = yield* RpcClient.make(TodoRpcs);
78
79 const created = yield* client.createTodo({ text: "write the example" });
80 expect(created.id).toBeNumber();
81 expect(created.text).toBe("write the example");
82 expect(created.done).toBe(false);
83
84 const afterCreate = yield* client.listTodos();
85 expect(afterCreate.some((t) => t.id === created.id)).toBe(true);
86
87 const toggled = yield* client.toggleTodo({
88 id: created.id,
89 done: true,
90 });
91 expect(toggled.done).toBe(true);
92
93 const deletedId = yield* client.deleteTodo({ id: created.id });
94 expect(deletedId).toBe(created.id);
95
96 const afterDelete = yield* client.listTodos();
97 expect(afterDelete.some((t) => t.id === created.id)).toBe(false);
98 }).pipe(Effect.provide(RpcProtocol)),
99 );
100
101 test(
102 "toggling a missing todo fails with TodoNotFound",
103 Effect.gen(function* () {
104 const client = yield* RpcClient.make(TodoRpcs);
105 const result = yield* client
106 .toggleTodo({ id: 2_147_483_000, done: true })
107 .pipe(Effect.flip);
108 expect(result._tag).toBe("TodoNotFound");
109 }).pipe(Effect.provide(RpcProtocol)),
110 );
111 },
112);