1import * as Cloudflare from "@/Cloudflare";
2import * as Alchemy from "@/index.ts";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Effect from "effect/Effect";
6import * as Schedule from "effect/Schedule";
7import * as HttpClient from "effect/http/HttpClient";
8import * as HttpClientRequest from "effect/http/HttpClientRequest";
9import { requestWorker } from "../Utils/WorkerRequest.ts";
10import DrizzleDurableObjectWorker from "./fixtures/drizzle-do/worker.ts";
11
12for (const dev of [true, false]) {
13 const state = dev ? Alchemy.inMemoryState() : Cloudflare.state();
14 const mode = dev ? "local" : "live";
15 describe(
16 mode,
17 { tags: ["provider:cloudflare", "provider:cloudflare:worker"] },
18 () => {
19 const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
20 providers: Cloudflare.providers(),
21 state,
22 dev,
23 });
24
25 /**
26 * End-to-end coverage for drizzle-kit's Durable Object migrations flow: the
27 * checked-in `fixtures/drizzle-do/drizzle/` directory is exactly what
28 * `drizzle-kit generate` emits for `driver: "durable-sqlite"` — a
29 * `migrations.js` importing each migration's `.sql` file — and the DO runs
30 * `drizzle-orm/durable-sqlite`'s `migrate` at instance init. The deploy
31 * itself asserts the bundler resolves bare `.sql` imports as text modules.
32 */
33 const Stack = Alchemy.Stack(
34 `DrizzleDurableObjectMigrationsStack-${mode}`,
35 { providers: Cloudflare.providers(), state },
36 Effect.gen(function* () {
37 const worker = yield* DrizzleDurableObjectWorker;
38 return { url: worker.url.as<string>() };
39 }),
40 );
41
42 const stack = beforeAll(deploy(Stack));
43 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
44
45 const readinessSchedule = Schedule.min([
46 Schedule.exponential("500 millis"),
47 Schedule.spaced("3 seconds"),
48 ]);
49
50 test(
51 `${mode}: DO runs drizzle migrations at init and serves drizzle queries`,
52 Effect.gen(function* () {
53 const { url } = yield* stack;
54 const client = yield* HttpClient.HttpClient;
55 // A fresh instance name per run so the migrated table starts empty
56 // even when the stack is kept alive between runs (NO_DESTROY).
57 const instance = yield* Effect.sync(() => crypto.randomUUID());
58
59 // Marker-anchored readiness: a freshly-enabled workers.dev hostname can
60 // serve Cloudflare's placeholder page with a 200 before the deployed
61 // Worker propagates, so gate on the route's JSON body, not the status.
62 const addUser = (name: string) =>
63 client.post(`${url}/users?do=${instance}&name=${name}`).pipe(
64 Effect.flatMap((res) => res.text),
65 Effect.flatMap((body) =>
66 body.includes(`"ok":true`)
67 ? Effect.succeed((JSON.parse(body) as { id: number }).id)
68 : Effect.fail(
69 new Error(`Worker not ready: ${body.slice(0, 200)}`),
70 ),
71 ),
72 Effect.retry({ schedule: readinessSchedule, times: 15 }),
73 );
74
75 const gimli = yield* addUser("gimli");
76 yield* addUser("legolas");
77 yield* client
78 .post(`${url}/posts?do=${instance}&user=${gimli}&title=axes`)
79 .pipe(Effect.flatMap((res) => res.json));
80
81 const res = yield* client.get(`${url}/users?do=${instance}`);
82 expect(res.status).toBe(200);
83 const body = (yield* res.json) as { names: string[] };
84 expect(body.names).toEqual(["gimli", "legolas"]);
85
86 // Relational query through the `relations` config.
87 const withPosts = yield* client.get(
88 `${url}/users-with-posts?do=${instance}`,
89 );
90 expect(withPosts.status).toBe(200);
91 const relational = (yield* withPosts.json) as {
92 users: { name: string; posts: string[] }[];
93 };
94 expect(relational.users).toEqual([
95 { name: "gimli", posts: ["axes"] },
96 { name: "legolas", posts: [] },
97 ]);
98
99 // Typed error handling: a failing query is caught inside the DO with
100 // Effect.catchTag rather than escaping as a defect.
101 const missing = yield* client.get(
102 `${url}/missing-table?do=${instance}`,
103 );
104 expect(missing.status).toBe(200);
105 const caught = (yield* missing.json) as { result: string };
106 expect(caught.result).toMatch(
107 /^caught:(SqlError|EffectDrizzleQueryError)$/,
108 );
109 }),
110 { tags: [...(dev ? ["local"] : ["live"])], timeout: 120_000 },
111 );
112
113 const requestRegression = (route: string, instanceName?: string) =>
114 Effect.gen(function* () {
115 const { url } = yield* stack;
116 if (dev) expect(url).toMatch(/^http:\/\/localhost:\d+$/);
117 const client = yield* HttpClient.HttpClient;
118 const instance =
119 instanceName ?? (yield* Effect.sync(() => crypto.randomUUID()));
120 const ready = yield* requestWorker(
121 HttpClientRequest.get(`${url}/users?do=${instance}`),
122 );
123 expect(ready.status).toBe(200);
124 expect(yield* ready.json).toEqual({ names: [] });
125 return yield* client.get(`${url}/${route}&do=${instance}`).pipe(
126 Effect.flatMap((response) =>
127 Effect.gen(function* () {
128 const body = yield* response.text;
129 if (response.status !== 200) {
130 return yield* Effect.fail(
131 new Error(
132 `Regression returned ${response.status}: ${body}`,
133 ),
134 );
135 }
136 return yield* Effect.sync(() => JSON.parse(body));
137 }),
138 ),
139 Effect.timeout("10 seconds"),
140 );
141 });
142
143 test(
144 `${mode}: SQLite clock RPC works independently of transaction input gates`,
145 Effect.gen(function* () {
146 const { url } = yield* stack;
147 const client = yield* HttpClient.HttpClient;
148 const probes = yield* Effect.forEach(
149 [false, true],
150 (view) =>
151 Effect.gen(function* () {
152 const instance = yield* Effect.sync(() => crypto.randomUUID());
153 yield* requestRegression("users?", instance);
154 const outcomes = yield* Effect.forEach(
155 [
156 `sqlite-gate?view=${view}`,
157 "sqlite-clock?direct=true",
158 "sqlite-clock?direct=false",
159 `sqlite-gate?view=${view}`,
160 ],
161 (route) =>
162 client.get(`${url}/${route}&do=${instance}`).pipe(
163 Effect.flatMap((response) =>
164 response.text.pipe(
165 Effect.map((body) => ({
166 route,
167 status: response.status,
168 body,
169 })),
170 ),
171 ),
172 Effect.timeout("10 seconds"),
173 ),
174 { concurrency: 1 },
175 );
176 return { instance, outcomes };
177 }),
178 { concurrency: 1 },
179 );
180 yield* Effect.logInfo("SQLite clock diagnostic", { url, probes });
181 for (const probe of probes) {
182 for (const outcome of probe.outcomes) {
183 expect(outcome.status).toBe(200);
184 const body = yield* Effect.sync(() => JSON.parse(outcome.body));
185 expect(body).toEqual(
186 outcome.route.startsWith("sqlite-gate")
187 ? {
188 enteredAfterOuter: false,
189 sameTransaction: true,
190 samePermit: true,
191 sameTransactionContext: true,
192 restoredScheduler: true,
193 finishedBeforeClear: true,
194 }
195 : { clock: "ready" },
196 );
197 }
198 }
199 }),
200 { tags: [...(dev ? ["local"] : ["live"])], timeout: 120_000 },
201 );
202
203 for (const view of [false, true]) {
204 test(
205 `${mode}: SQLite input gate yields before releasing the earlier timer (view=${view})`,
206 Effect.gen(function* () {
207 const result = yield* requestRegression(`sqlite-gate?view=${view}`);
208 expect(result).toEqual({
209 enteredAfterOuter: false,
210 sameTransaction: true,
211 samePermit: true,
212 sameTransactionContext: true,
213 restoredScheduler: true,
214 finishedBeforeClear: true,
215 });
216 }),
217 { tags: [...(dev ? ["local"] : ["live"])], timeout: 120_000 },
218 );
219 }
220
221 test(
222 `${mode}: SQLite failure, defect and interruption roll back and release the permit`,
223 Effect.gen(function* () {
224 const result = yield* requestRegression("sqlite-rollback?");
225 expect(result).toEqual({
226 failed: true,
227 defect: true,
228 interrupted: true,
229 waitingForPermit: true,
230 rowsAfterRollback: [],
231 finalRows: [{ name: "committed" }],
232 finalized: [
233 "caller:failed:true",
234 "caller:defect:true",
235 "caller:interrupted:true",
236 "caller:committed:true",
237 ],
238 callerScopePreserved: true,
239 });
240 }),
241 { tags: [...(dev ? ["local"] : ["live"])], timeout: 120_000 },
242 );
243 },
244 );
245}