1import { Action } from "@/Action";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import * as Alchemy from "@/index.ts";
4import * as Test from "@/Test/Alchemy";
5import * as d1 from "@distilled.cloud/cloudflare/d1";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Path from "effect/Path";
11import { MinimumLogLevel } from "effect/References";
12import * as Schedule from "effect/Schedule";
13import * as HttpClient from "effect/http/HttpClient";
14import * as pathe from "pathe";
15import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment.ts";
16
17// `dev: true` runs local providers behind the RPC sidecar proxy by default,
18// matching the process topology of the real `alchemy dev` command (see
19// MakeOptions.sidecar in Test/Core.ts).
20const { test, deploy, destroy } = Test.make({
21 providers: Cloudflare.providers(),
22 dev: true,
23});
24
25// The in-process topology: no RpcProviderProxy, so the RPC-backed local
26// provider builds directly in this process with the un-gated
27// `localRuntimeServices()` from its dual registration. Still a real
28// production path — a programmatic `deploy({ dev: true })` without the
29// `alchemy dev` CLI has no sidecar.
30const { test: inProcessTest } = Test.make({
31 providers: Cloudflare.providers(),
32 dev: true,
33 sidecar: false,
34});
35
36const logLevel = Effect.provideService(
37 MinimumLogLevel,
38 process.env.DEBUG ? "Debug" : "Info",
39);
40
41const RpcMigrationStack = Alchemy.Stack(
42 "D1RpcMigrationStack",
43 {
44 providers: Cloudflare.providers(),
45 state: Alchemy.localState(),
46 },
47 Cloudflare.D1.Database("RpcMigratedDB", {
48 migrations: pathe.resolve(import.meta.dirname, "fixtures/rpc-migrations"),
49 }),
50);
51
52class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
53 status: number;
54}> {}
55
56const getJsonReady = (url: string) =>
57 Effect.gen(function* () {
58 const client = yield* HttpClient.HttpClient;
59 const res = yield* client.get(url).pipe(
60 Effect.flatMap((res) =>
61 res.status === 200
62 ? Effect.succeed(res)
63 : Effect.fail(new WorkerNotReady({ status: res.status })),
64 ),
65 Effect.retry({
66 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
67 // Cap the backoff: an uncapped exponential over 10 recurs sums to
68 // ~8.5 minutes and turns a persistent non-200 into an apparent hang.
69 schedule: Schedule.max([
70 Schedule.min([
71 Schedule.exponential("500 millis"),
72 Schedule.spaced("2 seconds"),
73 ]),
74 Schedule.recurs(10),
75 ]),
76 }),
77 );
78 return yield* res.json;
79 }).pipe(Effect.orDie);
80
81/**
82 * Regression test for #1007. Under the RPC proxy used by `alchemy dev` (the
83 * default topology for `dev: true` tests), `localRuntimeServices()` is
84 * intentionally omitted from the caller because RPC providers consume it in
85 * the sidecar. D1's local provider is not an RPC provider, so its migration
86 * lifecycle must provide the standalone gateway runtime explicitly.
87 */
88test(
89 "D1 migrations apply with the alchemy dev RPC proxy",
90 Effect.gen(function* () {
91 yield* destroy(RpcMigrationStack);
92
93 const database = yield* deploy(RpcMigrationStack);
94
95 expect(database.databaseId).toMatch(/^dev:/);
96 expect(Object.keys(database.migrationsHashes)).toEqual(["0001_notes.sql"]);
97
98 yield* destroy(RpcMigrationStack);
99 }).pipe(logLevel),
100 {
101 tags: ["provider:cloudflare", "provider:cloudflare:d1", "local"],
102 timeout: 120_000,
103 },
104);
105
106/**
107 * Counterpart of the #1007 regression above for the in-process topology:
108 * without the proxy, the provider's migration lifecycle runs in this
109 * process and must find the workerd runtime in its own layer (the dual
110 * registration's `localRuntimeServices()`, real when un-gated).
111 */
112inProcessTest.provider(
113 "D1 migrations apply in-process without the RPC proxy",
114 (stack) =>
115 Effect.gen(function* () {
116 yield* stack.destroy();
117
118 const db = yield* stack.deploy(
119 Cloudflare.D1.Database("InProcessMigratedDB", {
120 migrations: pathe.resolve(
121 import.meta.dirname,
122 "fixtures/rpc-migrations",
123 ),
124 }),
125 );
126
127 expect(db.databaseId).toMatch(/^dev:/);
128 expect(Object.keys(db.migrationsHashes)).toEqual(["0001_notes.sql"]);
129
130 yield* stack.destroy();
131 }).pipe(logLevel),
132 {
133 tags: ["provider:cloudflare", "provider:cloudflare:d1", "local"],
134 timeout: 120_000,
135 },
136);
137
138/**
139 * Under `alchemy dev` the D1 Database resource is emulated by the local
140 * provider (a `dev:` id, no cloud API calls) and the worker's `d1` binding
141 * is lowered onto the local workerd D1 simulator (workerd's real
142 * `cloudflare-internal:d1-api` over DO SQLite). This exercises DDL,
143 * prepared statements, and reads through the native `env` binding.
144 */
145test.provider(
146 "D1 database binding round-trips against the local simulator",
147 (stack) =>
148 Effect.gen(function* () {
149 yield* stack.destroy();
150
151 const deployed = yield* stack.deploy(
152 Effect.gen(function* () {
153 const db = yield* Cloudflare.D1.Database("LocalDB");
154 const worker = yield* Cloudflare.Worker("d1-local-worker", {
155 main: pathe.resolve(
156 import.meta.dirname,
157 "fixtures/d1-local-worker.ts",
158 ),
159 env: { DB: db },
160 });
161 return { db, worker };
162 }),
163 );
164
165 // The local provider fabricates a `dev:` id — proof no cloud call ran.
166 expect(deployed.db.databaseId).toMatch(/^dev:/);
167
168 const body = (yield* getJsonReady(
169 `${deployed.worker.url}/roundtrip`,
170 )) as {
171 names: string[];
172 count: number | null;
173 };
174 expect(body.names).toEqual(["alice", "bob"]);
175 expect(body.count).toBe(2);
176
177 yield* stack.destroy();
178 }).pipe(logLevel),
179 {
180 tags: [
181 "provider:cloudflare",
182 "provider:cloudflare:d1",
183 "provider:cloudflare:worker",
184 "local",
185 ],
186 timeout: 120_000,
187 },
188);
189
190/**
191 * Migrations apply against the local simulator: reconcile boots an
192 * ephemeral gateway workerd and drives the same migration flow the live
193 * provider uses (Alchemy's `__alchemy_migrations` table, idempotent
194 * re-application). Verified through the deployed worker's binding — the
195 * same DO SQLite storage the gateway wrote to.
196 */
197test.provider(
198 "D1 migrations apply against the local simulator and re-apply incrementally",
199 (stack) =>
200 Effect.gen(function* () {
201 const fs = yield* FileSystem.FileSystem;
202 const path = yield* Path.Path;
203 const migrationsDir = yield* fs.makeTempDirectory({
204 prefix: "alchemy-d1-local-migrations-",
205 });
206 yield* fs.writeFileString(
207 path.join(migrationsDir, "0001_users.sql"),
208 [
209 "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
210 "INSERT INTO users (name) VALUES ('seed');",
211 ].join("\n"),
212 );
213
214 yield* stack.destroy();
215
216 const deploy = Effect.gen(function* () {
217 const db = yield* Cloudflare.D1.Database("LocalMigratedDB", {
218 migrations: migrationsDir,
219 });
220 const worker = yield* Cloudflare.Worker("d1-migrations-worker", {
221 main: pathe.resolve(
222 import.meta.dirname,
223 "fixtures/d1-local-worker.ts",
224 ),
225 env: { DB: db },
226 });
227 return { db, worker };
228 });
229
230 const v1 = yield* stack.deploy(deploy);
231 expect(v1.db.databaseId).toMatch(/^dev:/);
232 expect(v1.db.migrationsTable).toBe("__alchemy_migrations");
233 expect(Object.keys(v1.db.migrationsHashes)).toEqual(["0001_users.sql"]);
234
235 const url = v1.worker.url!;
236 const schema = (yield* getJsonReady(`${url}/tables`)) as {
237 tables: string[];
238 };
239 expect(schema.tables).toContain("users");
240 expect(schema.tables).toContain("__alchemy_migrations");
241
242 const users = (yield* getJsonReady(`${url}/users`)) as {
243 users: string[];
244 };
245 expect(users.users).toEqual(["seed"]);
246
247 // Add a second migration — the redeploy applies ONLY the new file
248 // (0001's seed row would fail a re-run with a duplicate table error,
249 // so a passing redeploy also proves idempotency).
250 yield* fs.writeFileString(
251 path.join(migrationsDir, "0002_posts.sql"),
252 "CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT NOT NULL);",
253 );
254
255 const v2 = yield* stack.deploy(deploy);
256 expect(v2.db.databaseId).toBe(v1.db.databaseId);
257 expect(Object.keys(v2.db.migrationsHashes).sort()).toEqual([
258 "0001_users.sql",
259 "0002_posts.sql",
260 ]);
261
262 // Alchemy's shape: INTEGER ids, name-keyed.
263 const migrations = (yield* getJsonReady(`${url}/migrations`)) as {
264 migrations: Array<{ id: number; name: string }>;
265 };
266 expect(migrations.migrations).toEqual([
267 { id: 1, name: "0001_users.sql" },
268 { id: 2, name: "0002_posts.sql" },
269 ]);
270
271 // Data written by 0001 survived the second deploy (not re-applied).
272 const usersAfter = (yield* getJsonReady(`${url}/users`)) as {
273 users: string[];
274 };
275 expect(usersAfter.users).toEqual(["seed"]);
276
277 yield* stack.destroy();
278 }).pipe(logLevel),
279 {
280 tags: [
281 "provider:cloudflare",
282 "provider:cloudflare:d1",
283 "provider:cloudflare:worker",
284 "local",
285 ],
286 timeout: 120_000,
287 },
288);
289
290/**
291 * `importFiles` apply locally too: an import file is multi-statement SQL,
292 * executed through the gateway with the same hash-skip semantics as the
293 * cloud import flow. Verified through the worker's binding.
294 */
295test.provider(
296 "D1 importFiles apply against the local simulator",
297 (stack) =>
298 Effect.gen(function* () {
299 const fs = yield* FileSystem.FileSystem;
300 const path = yield* Path.Path;
301 const dir = yield* fs.makeTempDirectory({
302 prefix: "alchemy-d1-local-import-",
303 });
304 const importFile = path.join(dir, "seed.sql");
305 yield* fs.writeFileString(
306 importFile,
307 [
308 "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
309 "INSERT INTO users (name) VALUES ('imported');",
310 ].join("\n"),
311 );
312
313 yield* stack.destroy();
314
315 const deployed = yield* stack.deploy(
316 Effect.gen(function* () {
317 const db = yield* Cloudflare.D1.Database("LocalImportedDB", {
318 importFiles: [importFile],
319 });
320 const worker = yield* Cloudflare.Worker("d1-import-worker", {
321 main: pathe.resolve(
322 import.meta.dirname,
323 "fixtures/d1-local-worker.ts",
324 ),
325 env: { DB: db },
326 });
327 return { db, worker };
328 }),
329 );
330
331 expect(deployed.db.databaseId).toMatch(/^dev:/);
332 expect(Object.keys(deployed.db.importHashes)).toEqual([importFile]);
333
334 const users = (yield* getJsonReady(`${deployed.worker.url}/users`)) as {
335 users: string[];
336 };
337 expect(users.users).toEqual(["imported"]);
338
339 yield* stack.destroy();
340 }).pipe(logLevel),
341 {
342 tags: [
343 "provider:cloudflare",
344 "provider:cloudflare:d1",
345 "provider:cloudflare:worker",
346 "local",
347 ],
348 timeout: 120_000,
349 },
350);
351
352/**
353 * `QueryDatabaseLocal` (the stack-eval capability used in Actions) reaches
354 * the LOCAL simulator when the database is a `dev:` row: queries tunnel
355 * through an ephemeral workerd gateway into the same DO SQLite the worker
356 * binding reads. The Action seeds; the deployed worker's native binding
357 * observes the seeded rows — proving both transports share one database.
358 */
359test.provider(
360 "QueryDatabaseLocal Action seeds the local simulator in dev",
361 (stack) =>
362 Effect.gen(function* () {
363 yield* stack.destroy();
364
365 const deployed = yield* stack.deploy(
366 Effect.gen(function* () {
367 const db = yield* Cloudflare.D1.Database("ActionSeededDB");
368
369 const Seed = Action(
370 "Seed",
371 Effect.gen(function* () {
372 const client = yield* Cloudflare.D1.QueryDatabase(db);
373 return Effect.fn(function* () {
374 yield* client.exec(
375 "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)",
376 );
377 yield* client.prepare("DELETE FROM users").run();
378 yield* client
379 .prepare("INSERT INTO users (name) VALUES (?)")
380 .bind("ada")
381 .run();
382 const rows = yield* client
383 .prepare("SELECT name FROM users ORDER BY name")
384 .all<{ name: string }>();
385 return { names: rows.results.map((r) => r.name) };
386 });
387 }).pipe(Effect.provide(Cloudflare.D1.QueryDatabaseLocal)),
388 );
389 const seeded = yield* Seed({});
390
391 const worker = yield* Cloudflare.Worker("d1-action-worker", {
392 main: pathe.resolve(
393 import.meta.dirname,
394 "fixtures/d1-local-worker.ts",
395 ),
396 env: { DB: db },
397 });
398 return { db, worker, seeded };
399 }),
400 );
401
402 expect(deployed.db.databaseId).toMatch(/^dev:/);
403 expect(deployed.seeded.names).toEqual(["ada"]);
404
405 // The worker's native binding reads the same simulator storage the
406 // Action's gateway wrote to.
407 const users = (yield* getJsonReady(`${deployed.worker.url}/users`)) as {
408 users: string[];
409 };
410 expect(users.users).toEqual(["ada"]);
411
412 yield* stack.destroy();
413 }).pipe(logLevel),
414 {
415 tags: [
416 "provider:cloudflare",
417 "provider:cloudflare:d1",
418 "provider:cloudflare:worker",
419 "local",
420 ],
421 timeout: 120_000,
422 },
423);
424
425/**
426 * `Alchemy.remote()` opts the database OUT of local emulation: even under
427 * `alchemy dev` it is created on real Cloudflare (a real UUID, not a `dev:`
428 * id) and the worker's `d1` binding proxies to it remotely. An out-of-band
429 * query through the cloud API proves the worker's writes landed in the real
430 * database, and destroy removes it.
431 */
432test.provider(
433 "Alchemy.remote() database runs live in dev",
434 (stack) =>
435 Effect.gen(function* () {
436 yield* stack.destroy();
437
438 const deployed = yield* stack.deploy(
439 Effect.gen(function* () {
440 const db = yield* Cloudflare.D1.Database("LiveDevDB").pipe(
441 Alchemy.remote(),
442 );
443 const worker = yield* Cloudflare.Worker("d1-live-worker", {
444 main: pathe.resolve(
445 import.meta.dirname,
446 "fixtures/d1-local-worker.ts",
447 ),
448 env: { DB: db },
449 });
450 return { db, worker };
451 }),
452 );
453
454 // A real UUID — the live provider created it on Cloudflare.
455 expect(deployed.db.databaseId).not.toMatch(/^dev:/);
456
457 const body = (yield* getJsonReady(
458 `${deployed.worker.url}/roundtrip`,
459 )) as {
460 names: string[];
461 };
462 expect(body.names).toEqual(["alice", "bob"]);
463
464 // Out-of-band: the rows are visible through the cloud API — the
465 // remote-proxied binding really hit the live database.
466 const { accountId } = yield* yield* CloudflareEnvironment;
467 const queryDb = yield* d1.queryDatabase;
468 const result = yield* queryDb({
469 accountId,
470 databaseId: deployed.db.databaseId,
471 sql: "SELECT name FROM users ORDER BY name;",
472 });
473 const names = (result.result[0]?.results ?? []) as Array<{
474 name: string;
475 }>;
476 expect(names.map((r) => r.name)).toEqual(["alice", "bob"]);
477
478 yield* stack.destroy();
479
480 // Destroy deleted the real database (stamped live mode).
481 const gone = yield* d1
482 .getDatabase({ accountId, databaseId: deployed.db.databaseId })
483 .pipe(
484 Effect.as(false),
485 Effect.catchTag("DatabaseNotFound", () => Effect.succeed(true)),
486 );
487 expect(gone).toBe(true);
488 }).pipe(logLevel),
489 {
490 tags: [
491 "provider:cloudflare",
492 "provider:cloudflare:d1",
493 "provider:cloudflare:worker",
494 "live",
495 ],
496 timeout: 120_000,
497 },
498);