1import * as Cloudflare from "@/Cloudflare";
2import * as Planetscale from "@/Planetscale";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import type { HttpClientResponse } from "effect/http/HttpClientResponse";
13import MySQLHyperdriveWorker from "./fixtures/hyperdrive-worker.ts";
14import type { Widget } from "./fixtures/schema.ts";
15import { Hyperdrive, PlanetscaleDb } from "./fixtures/Stack.ts";
16
17const providers = Layer.mergeAll(
18 Cloudflare.providers(),
19 Planetscale.providers(),
20);
21
22const { test } = Test.make({
23 providers,
24});
25
26const logLevel = Effect.provideService(
27 MinimumLogLevel,
28 process.env.DEBUG ? "Debug" : "Info",
29);
30
31class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
32 status: number;
33 body: string;
34}> {}
35
36const fetchReady = (req: Effect.Effect<any, any, any>) =>
37 req.pipe(
38 Effect.flatMap((res: any) =>
39 res.status >= 200 && res.status < 300
40 ? res.text.pipe(Effect.as(res))
41 : res.text.pipe(
42 Effect.flatMap((body: string) =>
43 Effect.fail(new WorkerNotReady({ status: res.status, body })),
44 ),
45 ),
46 ),
47 Effect.retry({
48 while: (e: unknown): e is WorkerNotReady =>
49 e instanceof WorkerNotReady && e.status >= 400 && e.status < 600,
50 schedule: Schedule.max([
51 Schedule.exponential("500 millis"),
52 Schedule.recurs(20),
53 ]),
54 }),
55 ) as Effect.Effect<HttpClientResponse>;
56
57const expectWidgetRoundTrip = (baseUrl: string, widget: Widget) =>
58 Effect.gen(function* () {
59 const initial = yield* fetchReady(HttpClient.get(`${baseUrl}/widgets`));
60 expect(initial.status).toBe(200);
61 const initialBody = (yield* initial.json) as { widgets: Widget[] };
62 expect(Array.isArray(initialBody.widgets)).toBe(true);
63
64 const insertRes = yield* fetchReady(
65 HttpClient.execute(
66 HttpClientRequest.post(`${baseUrl}/widgets`).pipe(
67 HttpClientRequest.bodyJsonUnsafe(widget),
68 ),
69 ),
70 );
71 expect(insertRes.status).toBe(200);
72 const insertBody = (yield* insertRes.json) as { widget: Widget };
73 expect(insertBody.widget).toMatchObject(widget);
74
75 const after = yield* fetchReady(HttpClient.get(`${baseUrl}/widgets`));
76 expect(after.status).toBe(200);
77 const afterBody = (yield* after.json) as { widgets: Widget[] };
78 expect(afterBody.widgets.some((w) => w.id === widget.id)).toBe(true);
79
80 const deleteRes = yield* fetchReady(
81 HttpClient.execute(
82 HttpClientRequest.delete(`${baseUrl}/widgets/${widget.id}`),
83 ),
84 );
85 expect(deleteRes.status).toBe(200);
86
87 const final = yield* fetchReady(HttpClient.get(`${baseUrl}/widgets`));
88 const finalBody = (yield* final.json) as { widgets: Widget[] };
89 expect(finalBody.widgets.some((w) => w.id === widget.id)).toBe(false);
90 });
91
92describe.skipIf(!process.env.PLANETSCALE_TEST).sequential(
93 "Hyperdrive",
94 {
95 tags: [
96 "provider:cloudflare",
97 "provider:cloudflare:hyperdrive",
98 "provider:cloudflare:worker",
99 "provider:planetscale",
100 "provider:planetscale:mysql",
101 "live",
102 ],
103 },
104 () => {
105 /**
106 * End-to-end: deploy a {@link Planetscale.MySQLDatabase} + branch (which
107 * applies the fixture migrations) + password, point a
108 * {@link Cloudflare.Hyperdrive.Connection} at the password's origin, and
109 * exercise the Drizzle Effect MySQL client over real MySQL via a Worker.
110 *
111 * Validates that:
112 * - migrations applied from the fixtures dir produce the expected table
113 * - `Cloudflare.Hyperdrive.Connect(...) + Drizzle.MySQL(...)` produces
114 * a working Effect-native client at runtime (text protocol +
115 * eval-free row parsers by default on workerd)
116 * - INSERT / SELECT / DELETE round-trip through Hyperdrive to PlanetScale
117 */
118 test.provider(
119 "MySQLBranch + Hyperdrive + Drizzle round-trips through a Worker",
120 (stack) =>
121 Effect.gen(function* () {
122 yield* stack.destroy();
123
124 const { hyperdrive, worker } = yield* stack.deploy(
125 Effect.gen(function* () {
126 yield* PlanetscaleDb;
127 const hyperdrive = yield* Hyperdrive;
128 const worker = yield* MySQLHyperdriveWorker;
129 return { hyperdrive, worker };
130 }),
131 );
132
133 expect(worker.url).toBeTypeOf("string");
134 expect(hyperdrive.origin).toMatchObject({ port: 3306 });
135 const baseUrl = (worker.url as string).replace(/\/+$/, "");
136 yield* expectWidgetRoundTrip(baseUrl, { id: 1, name: "alpha" });
137
138 yield* stack.destroy();
139 }).pipe(logLevel),
140 { timeout: 900_000 },
141 );
142 },
143);