1import * as Cloudflare from "@/Cloudflare";
2import * as Test from "@/Test/Alchemy";
3import { expect } from "alchemy-test";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import { MinimumLogLevel } from "effect/References";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import type * as HttpClientResponse from "effect/http/HttpClientResponse";
11import Stack from "./fixtures/stack.ts";
12
13const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
14 providers: Cloudflare.providers(),
15 state: Cloudflare.state(),
16});
17
18const HOOK_TIMEOUT = 300_000;
19const TEST_TIMEOUT = 120_000;
20
21const logLevel = Effect.provideService(
22 MinimumLogLevel,
23 process.env.DEBUG ? "Debug" : "Info",
24);
25
26class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
27 status: number;
28 body: string;
29}> {}
30
31// Bounded spaced schedule — caps total wait so a genuine failure surfaces
32// fast instead of an uncapped exponential blowing past the test timeout
33// while riding out fresh-workers.dev cold-start propagation.
34const ready = Schedule.max([Schedule.spaced("2 seconds"), Schedule.recurs(45)]);
35
36/** Retry an HTTP call until it returns 200 (rides out cold-start 404s). */
37const untilOk = <E, R>(
38 eff: Effect.Effect<HttpClientResponse.HttpClientResponse, E, R>,
39) =>
40 eff.pipe(
41 Effect.flatMap((res) =>
42 res.status === 200
43 ? Effect.succeed(res)
44 : res.text.pipe(
45 Effect.flatMap((body) =>
46 Effect.fail(new WorkerNotReady({ status: res.status, body })),
47 ),
48 ),
49 ),
50 Effect.retry({
51 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
52 schedule: ready,
53 }),
54 );
55
56class RowsMismatch extends Data.TaggedError("RowsMismatch")<{
57 actual: string;
58}> {}
59
60const retryRows = <A, E, R>(eff: Effect.Effect<A, E, R>) =>
61 eff.pipe(
62 Effect.retry({
63 while: (e: E) => e instanceof RowsMismatch,
64 schedule: ready,
65 }),
66 );
67
68/**
69 * Drive the full D1 client surface against ONE deployed worker over `fetch`:
70 * `exec` (/init) → `batch` (/seed) → `prepare.bind.run` (/users POST) →
71 * `prepare.bind.all` (/users GET) → `prepare.bind.first` (/users/:id) →
72 * `raw` (/raw). Both workers share one physical database; each stamps its
73 * rows with a distinct `style`, so the row-count assertions stay independent.
74 */
75const exercise = (base: string) =>
76 Effect.gen(function* () {
77 // exec — CREATE TABLE. Don't assert exact count (0 on a re-run), only shape.
78 const initRes = yield* untilOk(
79 HttpClient.execute(HttpClientRequest.post(`${base}/init`)),
80 );
81 const initBody = (yield* initRes.json) as {
82 count: number;
83 duration: number;
84 };
85 expect(typeof initBody.count).toBe("number");
86 expect(typeof initBody.duration).toBe("number");
87
88 // batch — three inserts in one transactional call.
89 const seedRes = yield* untilOk(
90 HttpClient.execute(HttpClientRequest.post(`${base}/seed`)),
91 );
92 expect(yield* seedRes.json).toMatchObject({ batches: 3, success: true });
93
94 // prepare.bind.run — single insert.
95 const insertRes = yield* untilOk(
96 HttpClient.execute(
97 HttpClientRequest.post(`${base}/users`).pipe(
98 HttpClientRequest.bodyJsonUnsafe({ id: 4, name: "dave" }),
99 ),
100 ),
101 );
102 expect(yield* insertRes.json).toMatchObject({
103 success: true,
104 meta: { changes: 1 },
105 });
106
107 // prepare.all — SELECT all rows for this style. D1 read-after-write is
108 // eventually consistent across edge locations, so retry until all four
109 // seeded/inserted rows are visible.
110 const expected = [
111 { id: 1, name: "alice" },
112 { id: 2, name: "bob" },
113 { id: 3, name: "carol" },
114 { id: 4, name: "dave" },
115 ];
116 const allBody = yield* untilOk(HttpClient.get(`${base}/users`)).pipe(
117 Effect.flatMap((res) => res.json),
118 Effect.flatMap((body) => {
119 const b = body as {
120 success: boolean;
121 results: Array<{ id: number; name: string }>;
122 };
123 return b.results.length === expected.length
124 ? Effect.succeed(b)
125 : Effect.fail(
126 new RowsMismatch({ actual: JSON.stringify(b.results) }),
127 );
128 }),
129 retryRows,
130 );
131 expect(allBody.success).toBe(true);
132 expect(allBody.results).toEqual(expected);
133
134 // prepare.bind.first — SELECT one row.
135 const oneRes = yield* untilOk(HttpClient.get(`${base}/users/2`));
136 expect(yield* oneRes.json).toEqual({ row: { id: 2, name: "bob" } });
137
138 // raw — direct runtime D1Database access (the Better Auth / Drizzle hatch).
139 // Retry until the count reflects all four rows (eventual consistency).
140 const rawBody = yield* untilOk(HttpClient.get(`${base}/raw`)).pipe(
141 Effect.flatMap((res) => res.json),
142 Effect.flatMap((body) => {
143 const b = body as { count: number };
144 return b.count === expected.length
145 ? Effect.succeed(b)
146 : Effect.fail(new RowsMismatch({ actual: JSON.stringify(b) }));
147 }),
148 retryRows,
149 );
150 expect(rawBody.count).toBe(4);
151 });
152
153/**
154 * End-to-end test of `Cloudflare.D1.QueryDatabase(...)` against a real
155 * Cloudflare Worker + D1 database, covering BOTH invocation styles against
156 * one shared database:
157 *
158 * - effect-worker: `yield* Cloudflare.D1.QueryDatabase(db)` in Init with
159 * `Cloudflare.D1.QueryDatabaseBinding` provided to the worker effect;
160 * - async-worker: the database declared on the Worker `env` (resolved to the
161 * native `cf.D1Database` via `InferEnv`) and used from a plain async fetch.
162 *
163 * The stack lives in `fixtures/stack.ts` so it can also be inspected directly,
164 * e.g. `alchemy logs --tail --stage test --config ./test/Cloudflare/D1/fixtures/stack.ts`.
165 */
166const stack = beforeAll(deploy(Stack), { timeout: HOOK_TIMEOUT });
167afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack), {
168 timeout: HOOK_TIMEOUT,
169});
170
171// ── effect-worker ── `yield* Cloudflare.D1.QueryDatabase(db)` + ConnectionBinding.
172test(
173 "effect-worker: QueryDatabase exercises the full client surface",
174 Effect.gen(function* () {
175 const out = yield* stack;
176 yield* exercise(out.effectWorkerUrl);
177 }).pipe(logLevel),
178 {
179 tags: [
180 "provider:cloudflare",
181 "provider:cloudflare:d1",
182 "provider:cloudflare:worker",
183 "live",
184 ],
185 timeout: TEST_TIMEOUT,
186 },
187);
188
189// ── async-worker ── D1 declared on `env`, native `env.DB` used from async fetch.
190test(
191 "async-worker: env-declared D1 binding exercises the full surface",
192 Effect.gen(function* () {
193 const out = yield* stack;
194 yield* exercise(out.asyncWorkerUrl);
195 }).pipe(logLevel),
196 {
197 tags: [
198 "provider:cloudflare",
199 "provider:cloudflare:d1",
200 "provider:cloudflare:worker",
201 "live",
202 ],
203 timeout: TEST_TIMEOUT,
204 },
205);