Skip to content
Source
Sign inSign up
Open History to see the last commit

DrizzleDurableObjectMigrations.test.ts

248 lines10.0 KB

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