1import { purePlugin } from "@/Bundle/PurePlugin.ts";
2import type { DurableObjectState } from "@/Cloudflare/Workers/DurableObjectState.ts";
3import {
4 SqlMigrations,
5 type SqlMigrationsInput,
6} from "@/Cloudflare/Workers/SqlMigrations.ts";
7import {
8 SqlMigrationsRuntime,
9 type SqlMigrationSnapshot,
10} from "@/Cloudflare/Workers/SqlMigrationsRuntime.ts";
11import { makeEffectVirtualEntry } from "@/Cloudflare/Workers/Sources/Rolldown.ts";
12import { Worker } from "@/Cloudflare/Workers/Worker.ts";
13import {
14 makeWorkerRuntimeContext,
15 type WorkerExport,
16 type WorkerRuntimeContext,
17} from "@/Cloudflare/Workers/WorkerRuntimeContext.ts";
18import type { RuntimeContext } from "@/RuntimeContext.ts";
19import {
20 DrizzleV0LayoutError,
21 MigrationError,
22 type MigrationHistoryConflictError,
23} from "@/SQL/Migrations/Format.ts";
24import { readMigrationRecords } from "@/SQL/Migrations/Records.ts";
25import { sha256 } from "@/Util/sha256.ts";
26import * as NodeServices from "@effect/platform-node/NodeServices";
27import { expect, layer } from "alchemy-test";
28import * as Cause from "effect/Cause";
29import * as Context from "effect/Context";
30import * as Effect from "effect/Effect";
31import * as Exit from "effect/Exit";
32import * as FileSystem from "effect/FileSystem";
33import * as Path from "effect/Path";
34import * as Stream from "effect/Stream";
35import * as ChildProcess from "effect/process/ChildProcess";
36import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
37import { nodePath, nodeSupportsDevMode } from "../../nodeProbe.ts";
38
39type Assert<T extends true> = T;
40type Application = ReturnType<
41 Effect.Success<ReturnType<typeof SqlMigrations>>["apply"]
42>;
43type _RequiresRuntimeContext = Assert<
44 RuntimeContext extends Effect.Services<Application> ? true : false
45>;
46type _RequiresDurableObjectState = Assert<
47 DurableObjectState extends Effect.Services<Application> ? true : false
48>;
49type _NotConstructionOnly = Assert<
50 Application extends Effect.Effect<
51 void,
52 MigrationError | MigrationHistoryConflictError,
53 Effect.Services<ReturnType<typeof SqlMigrations>>
54 >
55 ? false
56 : true
57>;
58type _RuntimeRequiredEvenWithState = Assert<
59 Application extends Effect.Effect<
60 void,
61 MigrationError | MigrationHistoryConflictError,
62 DurableObjectState
63 >
64 ? false
65 : true
66>;
67type _SnapshotHasNoMethod = Assert<
68 "apply" extends keyof SqlMigrationSnapshot ? false : true
69>;
70
71// Worker resolves its host through the resource's Self key.
72const WorkerHost = Context.Service<Worker, WorkerRuntimeContext>(
73 Worker.Self.key,
74);
75
76const writeMigrations = Effect.fn(function* (files: Record<string, string>) {
77 const fs = yield* FileSystem.FileSystem;
78 const path = yield* Path.Path;
79 const dir = yield* fs.makeTempDirectoryScoped({
80 prefix: "alchemy-sql-migrations-unit-",
81 });
82 for (const [name, contents] of Object.entries(files)) {
83 const file = path.join(dir, name);
84 yield* fs.makeDirectory(path.dirname(file), { recursive: true });
85 yield* fs.writeFileString(file, contents);
86 }
87 return dir;
88});
89
90const capture = Effect.fn(function* (input: SqlMigrationsInput) {
91 const worker = makeWorkerRuntimeContext("sql-migrations-unit");
92 const snapshot = yield* SqlMigrations(input).pipe(
93 Effect.provideService(WorkerHost, worker),
94 );
95 const { default: _default, ...exports } = yield* worker.exports;
96 const migrationExports: Record<string, WorkerExport> = exports;
97 return { worker, snapshot, exports: migrationExports };
98});
99
100const generatedEntry = (exports: Record<string, WorkerExport>) =>
101 makeEffectVirtualEntry(exports, {
102 name: "sql-migrations-unit",
103 stage: "test",
104 })("./worker.ts");
105
106layer(NodeServices.layer)("Cloudflare.SqlMigrations construction", (it) => {
107 it.effect(
108 "captures ordered flat files and their raw-content hashes",
109 () =>
110 Effect.gen(function* () {
111 const first = "CREATE TABLE users (id INTEGER PRIMARY KEY);\n";
112 const second = "CREATE TABLE posts (id INTEGER PRIMARY KEY);\n";
113 const dir = yield* writeMigrations({
114 "10_posts.sql": second,
115 "2_users.sql": first,
116 "ignored.txt": "not a migration",
117 });
118 const { worker, snapshot, exports } = yield* capture(dir);
119
120 expect(snapshot._tag).toBe("Cloudflare.SqlMigrations");
121 expect(snapshot.table).toBe("__alchemy_migrations");
122 expect(snapshot.records).toEqual(yield* readMigrationRecords(dir));
123 expect(snapshot.records.map((record) => record.name)).toEqual([
124 "2_users.sql",
125 "10_posts.sql",
126 ]);
127 expect(snapshot.records.map((record) => record.hash)).toEqual([
128 yield* sha256(first),
129 yield* sha256(second),
130 ]);
131 expect(snapshot.records.map((record) => record.sql)).toEqual([
132 first,
133 second,
134 ]);
135 expect(
136 snapshot.records.map((record) => record.createdAtMillis),
137 ).toEqual([undefined, undefined]);
138 expect(typeof snapshot.apply).toBe("function");
139 expect(Effect.isEffect(snapshot.apply())).toBe(true);
140 expect(Object.values(exports)).toEqual([
141 {
142 kind: "sqlMigrations",
143 snapshot: {
144 _tag: snapshot._tag,
145 table: snapshot.table,
146 records: snapshot.records,
147 },
148 },
149 ]);
150 for (const exported of Object.values(exports)) {
151 if (exported.kind === "sqlMigrations") {
152 expect(exported.snapshot).not.toHaveProperty("apply");
153 expect(exported.snapshot).not.toBe(snapshot);
154 }
155 }
156 expect(worker.env).toEqual({});
157 }).pipe(Effect.scoped),
158 {
159 tags: [
160 "unit",
161 "provider:cloudflare",
162 "provider:cloudflare:worker",
163 "local",
164 ],
165 },
166 );
167
168 it.effect(
169 "captures modern directories with timestamps and a custom table",
170 () =>
171 Effect.gen(function* () {
172 const first =
173 "CREATE TABLE users (id INTEGER PRIMARY KEY);\n--> statement-breakpoint\nINSERT INTO users VALUES (1);\n";
174 const second = "CREATE TABLE posts (id INTEGER PRIMARY KEY);\n";
175 const dir = yield* writeMigrations({
176 "20240102000000_posts/migration.sql": second,
177 "20240101000000_users/migration.sql": first,
178 "20240101000000_users/snapshot.json": "{}",
179 });
180 const { snapshot, exports, worker } = yield* capture({
181 dir,
182 table: "application_history",
183 });
184
185 expect(snapshot.table).toBe("application_history");
186 expect(snapshot.records).toEqual(yield* readMigrationRecords(dir));
187 expect(snapshot.records.map((record) => record.name)).toEqual([
188 "20240101000000_users",
189 "20240102000000_posts",
190 ]);
191 expect(
192 snapshot.records.map((record) => record.createdAtMillis),
193 ).toEqual([1704067200000, 1704153600000]);
194 expect(snapshot.records.map((record) => record.hash)).toEqual([
195 yield* sha256(first),
196 yield* sha256(second),
197 ]);
198 expect(snapshot.records[0]!.statements).toEqual([
199 "CREATE TABLE users (id INTEGER PRIMARY KEY);",
200 "INSERT INTO users VALUES (1);",
201 ]);
202 expect(Object.values(exports)).toEqual([
203 {
204 kind: "sqlMigrations",
205 snapshot: {
206 _tag: snapshot._tag,
207 table: snapshot.table,
208 records: snapshot.records,
209 },
210 },
211 ]);
212 expect(worker.env).toEqual({});
213 }).pipe(Effect.scoped),
214 {
215 tags: [
216 "unit",
217 "provider:cloudflare",
218 "provider:cloudflare:worker",
219 "local",
220 ],
221 },
222 );
223
224 it.effect(
225 "reconstructs apply from the plain runtime snapshot without reading the directory",
226 () =>
227 Effect.gen(function* () {
228 const fs = yield* FileSystem.FileSystem;
229 const path = yield* Path.Path;
230 const root = yield* writeMigrations({
231 "migrations/0001_users.sql":
232 "CREATE TABLE users (id INTEGER PRIMARY KEY);",
233 });
234 const dir = path.join(root, "migrations");
235 const input = { dir, table: "runtime_history" };
236 const captured = yield* capture(input);
237 const bundles: Record<string, SqlMigrationSnapshot> = {};
238 for (const [key, exported] of Object.entries(captured.exports)) {
239 if (exported.kind === "sqlMigrations") {
240 expect(exported.snapshot).not.toHaveProperty("apply");
241 bundles[key] = exported.snapshot;
242 }
243 }
244 expect(Object.keys(bundles)).toHaveLength(1);
245 yield* fs.remove(dir, { recursive: true });
246
247 const reconstructed = yield* Effect.acquireUseRelease(
248 Effect.sync(() => {
249 const previous = globalThis.__ALCHEMY_RUNTIME__;
250 globalThis.__ALCHEMY_RUNTIME__ = true;
251 return previous;
252 }),
253 () =>
254 SqlMigrations(input).pipe(
255 Effect.provideService(SqlMigrationsRuntime, bundles),
256 Effect.provideService(WorkerHost, captured.worker),
257 ),
258 (previous) =>
259 Effect.sync(() => {
260 globalThis.__ALCHEMY_RUNTIME__ = previous;
261 }),
262 );
263
264 expect(reconstructed._tag).toBe(captured.snapshot._tag);
265 expect(reconstructed.table).toBe(captured.snapshot.table);
266 expect(reconstructed.records).toEqual(captured.snapshot.records);
267 expect(typeof reconstructed.apply).toBe("function");
268 expect(Effect.isEffect(reconstructed.apply())).toBe(true);
269 expect(reconstructed.apply).not.toBe(captured.snapshot.apply);
270 for (const snapshot of Object.values(bundles)) {
271 expect(snapshot).not.toHaveProperty("apply");
272 }
273 }).pipe(Effect.scoped),
274 {
275 tags: [
276 "unit",
277 "provider:cloudflare",
278 "provider:cloudflare:worker",
279 "local",
280 ],
281 exclusive: true,
282 },
283 );
284
285 it.effect(
286 "fails construction for a missing directory before registering exports",
287 () =>
288 Effect.gen(function* () {
289 const path = yield* Path.Path;
290 const root = yield* writeMigrations({});
291 const dir = path.join(root, "missing");
292 const worker = makeWorkerRuntimeContext("missing-sql-migrations");
293 const exit = yield* SqlMigrations(dir).pipe(
294 Effect.provideService(WorkerHost, worker),
295 Effect.exit,
296 );
297
298 expect(Exit.isFailure(exit)).toBe(true);
299 if (Exit.isFailure(exit)) {
300 const error = Cause.squash(exit.cause);
301 expect(error).toBeInstanceOf(MigrationError);
302 expect(error).toMatchObject({
303 message: expect.stringContaining(dir),
304 });
305 }
306 expect(Object.keys(yield* worker.exports)).toEqual(["default"]);
307 expect(worker.env).toEqual({});
308 }).pipe(Effect.scoped),
309 {
310 tags: [
311 "unit",
312 "provider:cloudflare",
313 "provider:cloudflare:worker",
314 "local",
315 ],
316 },
317 );
318
319 it.effect(
320 "rejects legacy journals even when modern migration files exist",
321 () =>
322 Effect.gen(function* () {
323 const dir = yield* writeMigrations({
324 "meta/_journal.json": '{"version":"7","entries":[]}',
325 "20240101000000_users/migration.sql":
326 "CREATE TABLE users (id INTEGER);",
327 });
328 const worker = makeWorkerRuntimeContext("legacy-sql-migrations");
329 const exit = yield* SqlMigrations(dir).pipe(
330 Effect.provideService(WorkerHost, worker),
331 Effect.exit,
332 );
333
334 expect(Exit.isFailure(exit)).toBe(true);
335 if (Exit.isFailure(exit)) {
336 const error = Cause.squash(exit.cause);
337 expect(error).toBeInstanceOf(DrizzleV0LayoutError);
338 expect(error).toMatchObject({
339 dir,
340 message: expect.stringContaining("drizzle-kit up"),
341 });
342 }
343 expect(Object.keys(yield* worker.exports)).toEqual(["default"]);
344 }).pipe(Effect.scoped),
345 {
346 tags: [
347 "unit",
348 "provider:cloudflare",
349 "provider:cloudflare:worker",
350 "local",
351 ],
352 },
353 );
354
355 it.effect(
356 "embeds SQL larger than 5 KB without file imports or environment bindings",
357 () =>
358 Effect.gen(function* () {
359 const sql = `CREATE TABLE messages (body TEXT);\n--> statement-breakpoint\nINSERT INTO messages VALUES ('${"large-payload-".repeat(600)}');\n`;
360 expect(sql.length).toBeGreaterThan(5 * 1024);
361 const dir = yield* writeMigrations({ "0001_messages.sql": sql });
362 const { snapshot, exports, worker } = yield* capture({
363 dir,
364 table: "large_history",
365 });
366 const entry = generatedEntry(exports);
367 const snapshots = Object.fromEntries(
368 Object.entries(exports).map(([key]) => [key, snapshot]),
369 );
370
371 expect(snapshot.records[0]!.sql).toBe(sql);
372 expect(entry).toContain(
373 `withSqlMigrations(entrypoint, ${JSON.stringify(snapshots)})`,
374 );
375 expect(entry).toContain(JSON.stringify(sql));
376 expect(entry).toContain(snapshot.records[0]!.hash);
377 expect(entry).toContain('"table":"large_history"');
378 expect(entry).not.toMatch(
379 /\b(?:from\s*|import\s*(?:\(\s*)?)(["'])[^"']*\.sql(?:\?[^"']*)?\1/,
380 );
381 expect(entry).not.toContain("migrations.js");
382 expect(entry).not.toMatch(/\benv\s*(?:\.|\[)/);
383 expect(entry).not.toContain("process.env");
384 expect(entry).not.toContain("Records.ts");
385 expect(worker.env).toEqual({});
386 }).pipe(Effect.scoped),
387 {
388 tags: [
389 "unit",
390 "provider:cloudflare",
391 "provider:cloudflare:worker",
392 "local",
393 ],
394 },
395 );
396
397 it.effect(
398 "changes the generated entry when SQL changes under the same capture key",
399 () =>
400 Effect.gen(function* () {
401 const fs = yield* FileSystem.FileSystem;
402 const path = yield* Path.Path;
403 const firstSql = "CREATE TABLE first_version (id INTEGER);\n";
404 const secondSql = "CREATE TABLE second_version (id INTEGER);\n";
405 const dir = yield* writeMigrations({ "0001_schema.sql": firstSql });
406 const input = { dir, table: "stable_history" };
407 const first = yield* capture(input);
408 const firstEntry = generatedEntry(first.exports);
409 yield* fs.writeFileString(path.join(dir, "0001_schema.sql"), secondSql);
410 const second = yield* capture(input);
411 const secondEntry = generatedEntry(second.exports);
412
413 expect(Object.keys(second.exports)).toEqual(Object.keys(first.exports));
414 expect(second.snapshot.records[0]!.name).toBe(
415 first.snapshot.records[0]!.name,
416 );
417 expect(second.snapshot.records[0]!.hash).toBe(yield* sha256(secondSql));
418 expect(second.snapshot.records[0]!.hash).not.toBe(
419 first.snapshot.records[0]!.hash,
420 );
421 expect(secondEntry).not.toBe(firstEntry);
422 expect(firstEntry).toContain(JSON.stringify(firstSql));
423 expect(secondEntry).toContain(JSON.stringify(secondSql));
424 expect(secondEntry).not.toContain(JSON.stringify(firstSql));
425 expect(first.snapshot.records[0]!.sql).toBe(firstSql);
426 }).pipe(Effect.scoped),
427 {
428 tags: [
429 "unit",
430 "provider:cloudflare",
431 "provider:cloudflare:worker",
432 "local",
433 ],
434 },
435 );
436
437 it.effect(
438 "keeps deploy-time readers out of the emitted Durable Object migrator graph",
439 () =>
440 Effect.gen(function* () {
441 const path = yield* Path.Path;
442 const input = yield* path.fromFileUrl(
443 new URL(
444 "../../../src/Cloudflare/Workers/SqlMigrationsApply.ts",
445 import.meta.url,
446 ),
447 );
448 const { rolldown } = yield* Effect.promise(() => import("rolldown"));
449 const graph = new Set<string>();
450 const output = yield* Effect.acquireUseRelease(
451 Effect.promise(() =>
452 rolldown({
453 input,
454 platform: "neutral",
455 external: [/^effect\//, /^node:/],
456 plugins: [
457 {
458 name: "sql-migrations-runtime-boundary",
459 moduleParsed(info) {
460 graph.add(info.id);
461 for (const id of [
462 ...info.importedIds,
463 ...info.dynamicallyImportedIds,
464 ]) {
465 graph.add(id);
466 }
467 },
468 },
469 ],
470 }),
471 ),
472 (bundle) => Effect.promise(() => bundle.generate({ format: "esm" })),
473 (bundle) => Effect.promise(() => bundle.close()),
474 );
475 const chunks = output.output.filter((item) => item.type === "chunk");
476 const modules = [
477 ...graph,
478 ...chunks.flatMap((chunk) => Object.keys(chunk.modules)),
479 ];
480 const code = chunks.map((chunk) => chunk.code).join("\n");
481 const imports = chunks.flatMap((chunk) => [
482 ...chunk.imports,
483 ...chunk.dynamicImports,
484 ]);
485
486 expect(chunks.flatMap((chunk) => chunk.exports)).toContain(
487 "applySqlMigrations",
488 );
489 expect(
490 modules.some((id) => id.endsWith("/SQL/Migrations/AlchemyFormat.ts")),
491 ).toBe(true);
492 expect(
493 modules.some((id) => id.endsWith("/SQL/Migrations/Convert.ts")),
494 ).toBe(true);
495 expect(
496 modules.some((id) => id.endsWith("/SQL/Migrations/Utils.ts")),
497 ).toBe(true);
498 const forbidden =
499 /(?:SQL\/Migrations\/(?:Records|Registry|Detect)\.ts|SQL\/SqlFile\.ts|node:(?:crypto|fs)|effect\/(?:FileSystem|Path))/;
500 expect(modules.filter((id) => forbidden.test(id))).toEqual([]);
501 expect(imports.filter((id) => forbidden.test(id))).toEqual([]);
502 expect(code).not.toContain("node:crypto");
503 expect(code).not.toContain("readMigrationRecords");
504 }),
505 {
506 tags: [
507 "unit",
508 "provider:cloudflare",
509 "provider:cloudflare:worker",
510 "local",
511 ],
512 },
513 );
514
515 it.effect(
516 "emits the SqlMigrations factory and apply method without filesystem imports",
517 () =>
518 Effect.gen(function* () {
519 const path = yield* Path.Path;
520 const input = yield* path.fromFileUrl(
521 new URL(
522 "../../../src/Cloudflare/Workers/SqlMigrations.ts",
523 import.meta.url,
524 ),
525 );
526 const { rolldown } = yield* Effect.promise(() => import("rolldown"));
527 const output = yield* Effect.acquireUseRelease(
528 Effect.promise(() =>
529 rolldown({
530 input,
531 platform: "neutral",
532 external: (id) => !id.startsWith(".") && !path.isAbsolute(id),
533 transform: {
534 define: { "globalThis.__ALCHEMY_RUNTIME__": "true" },
535 },
536 plugins: [purePlugin()],
537 }),
538 ),
539 (bundle) =>
540 Effect.promise(() =>
541 bundle.generate({ format: "esm", minify: "dce-only" }),
542 ),
543 (bundle) => Effect.promise(() => bundle.close()),
544 );
545 const chunks = output.output.filter((item) => item.type === "chunk");
546 const modules = chunks.flatMap((chunk) =>
547 Object.entries(chunk.modules)
548 .filter(([, module]) => module.renderedLength > 0)
549 .map(([id]) => id),
550 );
551 const imports = chunks.flatMap((chunk) => [
552 ...chunk.imports,
553 ...chunk.dynamicImports,
554 ]);
555 const code = chunks.map((chunk) => chunk.code).join("\n");
556
557 expect(chunks.flatMap((chunk) => chunk.exports)).toContain(
558 "SqlMigrations",
559 );
560 expect(
561 modules.some((id) => id.endsWith("/Workers/SqlMigrations.ts")),
562 ).toBe(true);
563 expect(
564 modules.some((id) => id.endsWith("/Workers/SqlMigrationsApply.ts")),
565 ).toBe(true);
566 const forbidden =
567 /(?:SQL\/Migrations\/(?:Records|Registry|Detect)\.ts|SQL\/SqlFile\.ts|node:(?:crypto|fs)|effect\/(?:FileSystem|Path)|@effect\/platform-(?:node|bun))/;
568 expect(modules.filter((id) => forbidden.test(id))).toEqual([]);
569 expect(imports.filter((id) => forbidden.test(id))).toEqual([]);
570 expect(code).not.toContain("readMigrationRecords");
571 expect(code).not.toContain("node:fs");
572 expect(code).not.toContain("node:crypto");
573 }),
574 {
575 tags: [
576 "unit",
577 "provider:cloudflare",
578 "provider:cloudflare:worker",
579 "local",
580 ],
581 },
582 );
583
584 it.effect.skipIf(!nodeSupportsDevMode)(
585 "imports a SqlMigrations Durable Object through Node's Oxc loader and captures SQL",
586 () =>
587 Effect.gen(function* () {
588 const path = yield* Path.Path;
589 const spawner = yield* ChildProcessSpawner;
590 const sql =
591 "CREATE TABLE node_loader_probe (id INTEGER PRIMARY KEY);\n";
592 const root = yield* writeMigrations({
593 "migrations/0001_probe.sql": sql,
594 });
595 const loader = yield* path.fromFileUrl(
596 new URL("../../../bin/register-dev-mode.js", import.meta.url),
597 );
598 const fixture = yield* path.fromFileUrl(
599 new URL(
600 "./fixtures/sql-migrations-unit/node-import.ts",
601 import.meta.url,
602 ),
603 );
604 const handle = yield* spawner.spawn(
605 ChildProcess.make(nodePath!, ["--import", loader, fixture], {
606 cwd: root,
607 env: { NO_COLOR: "1", npm_execpath: "", npm_config_user_agent: "" },
608 extendEnv: true,
609 stdin: "ignore",
610 stdout: "pipe",
611 stderr: "pipe",
612 killSignal: "SIGKILL",
613 }),
614 );
615 const [stdout, stderr, exitCode] = yield* Effect.all(
616 [
617 handle.stdout.pipe(
618 Stream.decodeText,
619 Stream.runCollect,
620 Effect.map((chunks) => chunks.join("")),
621 ),
622 handle.stderr.pipe(
623 Stream.decodeText,
624 Stream.runCollect,
625 Effect.map((chunks) => chunks.join("")),
626 ),
627 handle.exitCode,
628 ],
629 { concurrency: 3 },
630 );
631
632 expect(stderr).toBe("");
633 expect(exitCode).toBe(0);
634 expect(stdout).toContain('"runtime":"node"');
635 expect(stdout).toContain('"object":"SqlMigrationsUnitObject"');
636 expect(stdout).toContain('"table":"node_loader_history"');
637 expect(stdout).toContain(JSON.stringify(sql));
638 expect(stdout).toContain(yield* sha256(sql));
639 expect(stdout).toContain('"capturedHasApply":true');
640 expect(stdout).toContain('"exportedHasApply":false');
641 expect(stdout).toContain('"exports":1');
642 expect(stdout).toContain('"env":{}');
643 }).pipe(Effect.scoped),
644 {
645 tags: [
646 "unit",
647 "provider:cloudflare",
648 "provider:cloudflare:worker",
649 "local",
650 ],
651 timeout: 60_000,
652 },
653 );
654});