1import * as Cloudflare from "@/Cloudflare/index.ts";
2import * as Test from "@/Test/Alchemy.ts";
3import { describe, expect } from "alchemy-test";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientResponse from "effect/http/HttpClientResponse";
11import UpgradeWorker from "./fixtures/sql-migrations-upgrade/worker.ts";
12
13type State = { id: string; count: number; rows: { value: string }[] };
14
15for (const dev of [true, false]) {
16 describe(
17 dev ? "local SQL migration updates" : "live SQL migration updates",
18 { tags: ["provider:cloudflare", "provider:cloudflare:worker"] },
19 () => {
20 const { test } = Test.make({
21 providers: Cloudflare.providers(),
22 dev,
23 stage: dev
24 ? "sql-migrations-upgrade-local"
25 : "sql-migrations-upgrade-live",
26 });
27 test.provider(
28 "SQL-only changes update existing objects without replay or replacement",
29 (stack) =>
30 Effect.gen(function* () {
31 yield* stack.destroy();
32 const fs = yield* FileSystem.FileSystem;
33 const path = yield* Path.Path;
34 const dir = yield* fs.makeTempDirectoryScoped();
35 yield* fs.writeFileString(
36 path.join(dir, "0001_init.sql"),
37 "CREATE TABLE items (value TEXT NOT NULL); INSERT INTO items VALUES ('seed');",
38 );
39 const config = ConfigProvider.orElse(
40 ConfigProvider.fromUnknown({ SQL_MIGRATIONS_DIRECTORY: dir }),
41 yield* ConfigProvider.ConfigProvider,
42 );
43 const deploy = stack
44 .deploy(
45 Effect.gen(function* () {
46 const worker = yield* UpgradeWorker;
47 return {
48 url: worker.url.as<string>(),
49 namespaces: worker.durableObjectNamespaces,
50 };
51 }),
52 )
53 .pipe(
54 Effect.provideService(ConfigProvider.ConfigProvider, config),
55 );
56 const first = yield* deploy;
57 const client = yield* HttpClient.HttpClient;
58 const read = (url: string) =>
59 client.get(url).pipe(
60 Effect.flatMap((response) =>
61 Effect.gen(function* () {
62 if (response.status !== 200)
63 return yield* Effect.fail(
64 new Error(
65 `GET ${url}: ${response.status}: ${yield* response.text}`,
66 ),
67 );
68 return yield* response.json;
69 }),
70 ),
71 Effect.map((value) => value as State),
72 Effect.timeout("5 seconds"),
73 );
74 const ready = (url: string, count: number) =>
75 read(url).pipe(
76 Effect.filterOrFail(
77 (state) => state.count === count,
78 () => new Error("Migration version not ready"),
79 ),
80 Effect.retry({
81 schedule: Schedule.spaced("3 seconds"),
82 times: 10,
83 }),
84 Effect.timeout("45 seconds"),
85 );
86 const original = yield* ready(first.url, 1);
87 expect(original.rows).toEqual([{ value: "seed" }]);
88 yield* client
89 .post(first.url)
90 .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
91 yield* fs.writeFileString(
92 path.join(dir, "0002_append.sql"),
93 "INSERT INTO items VALUES ('must-rollback'); INSERT INTO missing_table VALUES (1);",
94 );
95 const broken = yield* deploy;
96 const failed = yield* client.get(broken.url).pipe(
97 Effect.flatMap((response) =>
98 response.text.pipe(
99 Effect.map((body) => ({ status: response.status, body })),
100 ),
101 ),
102 Effect.timeout("5 seconds"),
103 Effect.repeat({
104 until: (response) =>
105 response.status === 500 &&
106 response.body.includes("Failed to apply SQL migration"),
107 schedule: Schedule.spaced("3 seconds"),
108 times: 10,
109 }),
110 Effect.timeout("45 seconds"),
111 );
112 expect(failed.status).toBe(500);
113 expect(failed.body).toContain("Failed to apply SQL migration");
114 yield* fs.writeFileString(
115 path.join(dir, "0002_append.sql"),
116 "INSERT INTO items VALUES ('migration-two');",
117 );
118 const second = yield* deploy;
119 expect(second.namespaces).toEqual(first.namespaces);
120 const upgraded = yield* ready(second.url, 2);
121 expect(upgraded.id).toBe(original.id);
122 expect(upgraded.rows).toEqual([
123 { value: "seed" },
124 { value: "user-data" },
125 { value: "migration-two" },
126 ]);
127 const third = yield* deploy;
128 // Worker and Durable Object versions can propagate independently.
129 expect(yield* ready(third.url, 2)).toEqual(upgraded);
130 yield* stack.destroy();
131 }),
132 { tags: [...(dev ? ["local"] : ["live"])], timeout: 120_000 },
133 );
134 },
135 );
136}