1import { RailwayAuth } from "@/Railway/AuthProvider.ts";
2import { fromAuthProvider } from "@/Railway/Credentials.ts";
3import { Query } from "@distilled.cloud/core/query";
4import { GraphQLLive, Railway as RailwayApi } from "@distilled.cloud/railway";
5import * as Alchemy from "@/index.ts";
6import * as Provider from "@/Provider";
7import * as Railway from "@/Railway";
8import { suitePartition } from "./suiteProject.ts";
9import { waitUntilVolumeGone } from "./waitUntilVolumeGone.ts";
10import * as Test from "@/Test/Alchemy";
11import { describe, expect } from "alchemy-test";
12import * as Data from "effect/Data";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import { MinimumLogLevel } from "effect/References";
16import * as Schedule from "effect/Schedule";
17import * as HttpClient from "effect/http/HttpClient";
18import MySQLApi, { Db, Site } from "./fixtures/mysql-api.ts";
19
20const { test } = Test.make({
21 providers: Railway.providers(),
22});
23
24const logLevel = Effect.provideService(
25 MinimumLogLevel,
26 process.env.DEBUG ? "Debug" : "Info",
27);
28
29const distilled = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
30 effect.pipe(
31 Effect.provide(
32 Layer.merge(
33 GraphQLLive,
34 fromAuthProvider().pipe(Layer.provide(RailwayAuth)),
35 ),
36 ),
37 );
38
39const firstOk = (rows: unknown): unknown => {
40 if (!Array.isArray(rows) || rows[0] == null || typeof rows[0] !== "object") {
41 return undefined;
42 }
43 return (rows[0] as { ok?: unknown }).ok;
44};
45
46const selectOne = (url: string) =>
47 Effect.tryPromise({
48 try: async () => {
49 const mysql = await import("mysql2/promise");
50 const connection = await mysql.createConnection({
51 uri: url,
52 connectTimeout: 5_000,
53 });
54 try {
55 const [rows] = await connection.query({
56 sql: "select 1 as ok",
57 timeout: 5_000,
58 });
59 return rows;
60 } finally {
61 connection.destroy();
62 }
63 },
64 catch: (cause) => new Error(String(cause)),
65 // Four attempts permit at most 40 seconds of connection/query work
66 // and six seconds of backoff; cleanup never waits on a stuck server.
67 }).pipe(Effect.retry({ schedule: Schedule.spaced("2 seconds"), times: 3 }));
68
69const readVariables = Query.fn(
70 (projectId: string, environmentId: string, serviceId: string) =>
71 RailwayApi.variables({
72 projectId,
73 environmentId,
74 serviceId,
75 unrendered: true,
76 }),
77);
78
79const readServiceDeletedAt = Query.fn((id: string) => ({
80 deletedAt: RailwayApi.service({ id }).deletedAt,
81}));
82
83const readService = Query.fn((id: string) => {
84 const service = RailwayApi.service({ id });
85 return {
86 id: service.id,
87 name: service.name,
88 projectId: service.projectId,
89 deletedAt: service.deletedAt,
90 };
91});
92
93const readServiceInstance = Query.fn(
94 (environmentId: string, serviceId: string) => {
95 const instance = RailwayApi.serviceInstance({ environmentId, serviceId });
96 return {
97 serviceId: instance.serviceId,
98 image: instance.source.image,
99 startCommand: instance.startCommand,
100 };
101 },
102);
103
104const readVolumeInstance = Query.fn((id: string) => {
105 const volume = RailwayApi.volumeInstance({ id });
106 return {
107 id: volume.id,
108 volumeId: volume.volumeId,
109 mountPath: volume.mountPath,
110 serviceId: volume.serviceId,
111 };
112});
113
114const readTcpProxies = Query.fn((environmentId: string, serviceId: string) =>
115 RailwayApi.tcpProxies({ environmentId, serviceId }).pipe(
116 Query.map((proxy) => ({
117 id: proxy.id,
118 domain: proxy.domain,
119 proxyPort: proxy.proxyPort,
120 applicationPort: proxy.applicationPort,
121 deletedAt: proxy.deletedAt,
122 syncStatus: proxy.syncStatus,
123 })),
124 ),
125);
126
127const asVariableMap = (value: unknown): Record<string, string> => {
128 if (value === null || typeof value !== "object" || Array.isArray(value)) {
129 return {};
130 }
131 const out: Record<string, string> = {};
132 for (const [key, item] of Object.entries(value)) {
133 if (typeof item === "string") {
134 out[key] = item;
135 }
136 }
137 return out;
138};
139
140const readServiceVariables = (
141 projectId: string,
142 environmentId: string,
143 serviceId: string,
144) =>
145 readVariables(projectId, environmentId, serviceId).pipe(
146 Effect.map(asVariableMap),
147 Effect.catchTag("RailwayNotFound", () =>
148 Effect.succeed({} as Record<string, string>),
149 ),
150 );
151
152const waitUntilServiceGone = (serviceId: string) =>
153 readServiceDeletedAt(serviceId).pipe(
154 Effect.map((service) =>
155 service.deletedAt != null ? ("gone" as const) : ("found" as const),
156 ),
157 Effect.catchTag("RailwayNotFound", () => Effect.succeed("gone" as const)),
158 Effect.repeat({
159 schedule: Schedule.spaced("1 second"),
160 until: (status) => status === "gone",
161 times: 10,
162 }),
163 );
164
165class NotReady extends Data.TaggedError("NotReady")<{
166 status: number;
167 body?: unknown;
168}> {
169 override get message() {
170 return this.body === undefined
171 ? `status ${this.status}`
172 : `status ${this.status}: ${JSON.stringify(this.body)}`;
173 }
174}
175
176const FixtureStack = Alchemy.Stack(
177 "RailwayMySQLFixture",
178 {
179 providers: Railway.providers(),
180 state: Alchemy.localState(),
181 },
182 Effect.gen(function* () {
183 const project = yield* Site;
184 const db = yield* Db;
185 const api = yield* MySQLApi;
186 return {
187 projectId: project.projectId,
188 environmentId: db.environmentId,
189 serviceId: api.serviceId,
190 url: api.url,
191 publicConnectionUri: db.publicConnectionUri,
192 mode: "effect" as const,
193 };
194 }),
195);
196
197test.provider(
198 "create, select 1, update, list, and delete mysql",
199 (stack) =>
200 Effect.gen(function* () {
201 yield* stack.destroy();
202
203 const created = yield* stack.deploy(
204 Effect.gen(function* () {
205 const { project, environment } = yield* suitePartition;
206 const db = yield* Railway.MySQL("Db", { project, environment });
207 return { project, environment, db };
208 }),
209 );
210
211 expect(created.db.serviceId).toEqual(expect.any(String));
212 expect(created.db.serviceId.length).toBeGreaterThan(0);
213 expect(created.db.projectId).toEqual(created.project.projectId);
214 expect(created.db.environmentId).toEqual(
215 created.environment.environmentId,
216 );
217 expect(created.db.name).toEqual(expect.any(String));
218 expect(created.db.name.length).toBeGreaterThan(0);
219 expect(created.db.name.length).toBeLessThanOrEqual(32);
220 expect(created.db.name).toMatch(/^[a-z][a-z0-9-]*$/);
221 expect(created.db.user).toEqual("root");
222 expect(created.db.database).toEqual("railway");
223 expect(created.db.volumeId.length).toBeGreaterThan(0);
224 expect(created.db.volumeInstanceId.length).toBeGreaterThan(0);
225 expect(created.db.tcpProxyId).toEqual(expect.any(String));
226 expect(created.db.tcpProxyDomain).toEqual(expect.any(String));
227 expect(created.db.tcpProxyDomain).toContain("proxy.rlwy.net");
228 expect(created.db.tcpProxyPort).toEqual(expect.any(Number));
229 expect(created.db.tcpProxyPort).toBeGreaterThan(0);
230 expect(
231 created.db.connectionUri.includes(
232 `${created.db.name}.railway.internal`,
233 ),
234 ).toEqual(true);
235 expect(
236 created.db.publicConnectionUri.includes(created.db.tcpProxyDomain!),
237 ).toEqual(true);
238
239 const fetched = yield* readService(created.db.serviceId);
240 expect(fetched.id).toEqual(created.db.serviceId);
241 expect(fetched.name).toEqual(created.db.name);
242 expect(fetched.projectId).toEqual(created.db.projectId);
243 expect(fetched.deletedAt).toBeNull();
244
245 const instance = yield* readServiceInstance(
246 created.db.environmentId,
247 created.db.serviceId,
248 );
249 expect(instance.serviceId).toEqual(created.db.serviceId);
250 expect(instance.image).toEqual(expect.stringContaining("mysql"));
251 expect(instance.startCommand).toEqual(
252 Railway.DEFAULT_MYSQL_START_COMMAND,
253 );
254
255 const volume = yield* readVolumeInstance(created.db.volumeInstanceId);
256 expect(volume.id).toEqual(created.db.volumeInstanceId);
257 expect(volume.volumeId).toEqual(created.db.volumeId);
258 expect(volume.mountPath).toEqual("/var/lib/mysql");
259 expect(volume.serviceId).toEqual(created.db.serviceId);
260
261 const proxies = yield* readTcpProxies(
262 created.db.environmentId,
263 created.db.serviceId,
264 );
265 const liveProxy = proxies.find(
266 (proxy) => proxy.deletedAt == null && proxy.syncStatus !== "DELETED",
267 );
268 expect(liveProxy).toBeDefined();
269 expect(liveProxy?.id).toEqual(created.db.tcpProxyId);
270 expect(liveProxy?.applicationPort).toEqual(3306);
271
272 const vars = yield* readServiceVariables(
273 created.db.projectId,
274 created.db.environmentId,
275 created.db.serviceId,
276 );
277 expect(vars.MYSQLHOST).toEqual("${{RAILWAY_PRIVATE_DOMAIN}}");
278 expect(vars.MYSQLPORT).toEqual("3306");
279 expect(vars.MYSQLUSER).toEqual("root");
280 expect((vars.MYSQLPASSWORD ?? "").length).toBeGreaterThan(0);
281 expect(vars.MYSQLDATABASE).toEqual("railway");
282 expect((vars[Railway.MYSQL_URL_SECRET] ?? "").length).toBeGreaterThan(0);
283 expect(
284 (vars[Railway.MYSQL_PUBLIC_URL_SECRET] ?? "").length,
285 ).toBeGreaterThan(0);
286
287 const provider = yield* Provider.findProvider(Railway.MySQL);
288 yield* Effect.logDebug("MySQL test: provider.list started");
289 const listed = yield* provider.list();
290 yield* Effect.logDebug("MySQL test: provider.list completed");
291 const found = listed.find(
292 (row) => row.serviceId === created.db.serviceId,
293 );
294 expect(found).toBeDefined();
295 expect(found?.name).toEqual(created.db.name);
296 expect(found?.projectId).toEqual(created.db.projectId);
297
298 yield* Effect.logDebug("MySQL test: SELECT 1 started");
299 const rows = yield* selectOne(created.db.publicConnectionUri);
300 yield* Effect.logDebug("MySQL test: SELECT 1 completed");
301 expect(firstOk(rows)).toEqual(1);
302
303 const nextName =
304 created.db.name.slice(0, -1) +
305 (created.db.name.endsWith("z") ? "y" : "z");
306
307 yield* Effect.logDebug("MySQL test: update started");
308 const updated = yield* stack.deploy(
309 Effect.gen(function* () {
310 const { project, environment } = yield* suitePartition;
311 const db = yield* Railway.MySQL("Db", {
312 project,
313 environment,
314 name: nextName,
315 });
316 return { project, environment, db };
317 }),
318 );
319
320 yield* Effect.logDebug("MySQL test: update completed");
321 expect(updated.db.serviceId).toEqual(created.db.serviceId);
322 expect(updated.db.name).toEqual(nextName);
323 expect(updated.db.projectId).toEqual(created.db.projectId);
324 expect(updated.db.volumeId).toEqual(created.db.volumeId);
325 expect(
326 updated.db.connectionUri.includes(`${nextName}.railway.internal`),
327 ).toEqual(true);
328
329 const fetchedUpdate = yield* readService(updated.db.serviceId);
330 expect(fetchedUpdate.id).toEqual(updated.db.serviceId);
331 expect(fetchedUpdate.name).toEqual(nextName);
332
333 yield* stack.destroy();
334
335 const gone = yield* waitUntilServiceGone(created.db.serviceId);
336 expect(gone).toEqual("gone");
337 const volumeGone = yield* waitUntilVolumeGone(
338 created.db.volumeInstanceId,
339 );
340 expect(volumeGone).toEqual("gone");
341 }).pipe(logLevel),
342 {
343 tags: [
344 "provider:railway",
345 "provider:railway:mysql",
346 "provider:railway:project",
347 "provider:railway:projectenvironment",
348 "provider:railway:service",
349 "provider:railway:variable",
350 "live",
351 ],
352 timeout: 120_000,
353 },
354);
355
356// Runtime fixture failures must not prevent the independent resource lifecycle test.
357describe(
358 "ConnectMySQL runtime integrations",
359 {
360 tags: [
361 "provider:railway",
362 "provider:railway:mysql",
363 "provider:railway:project",
364 "provider:railway:projectenvironment",
365 "provider:railway:service",
366 "provider:railway:variable",
367 "live",
368 ],
369 },
370 () => {
371 const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
372 providers: Railway.providers(),
373 });
374
375 const fixture = beforeAll(deploy(FixtureStack), {
376 timeout: 120_000,
377 });
378 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(FixtureStack), {
379 timeout: 120_000,
380 });
381
382 test(
383 "a Service connects and SELECTs through ConnectMySQL",
384 Effect.gen(function* () {
385 const out = yield* fixture;
386 expect(out.serviceId).toEqual(expect.any(String));
387 expect(out.serviceId.length).toBeGreaterThan(0);
388 expect(out.url).toEqual(expect.any(String));
389 expect(out.url).toContain("up.railway.app");
390
391 const fetched = yield* distilled(readService(out.serviceId));
392 expect(fetched.id).toEqual(out.serviceId);
393 expect(fetched.deletedAt).toBeNull();
394
395 const vars = yield* distilled(
396 readServiceVariables(out.projectId, out.environmentId, out.serviceId),
397 );
398 expect((vars[Railway.MYSQL_URL_SECRET] ?? "").length).toBeGreaterThan(
399 0,
400 );
401
402 const client = yield* HttpClient.HttpClient;
403 const get = (path: string) =>
404 client.get(`${out.url}${path}`).pipe(
405 Effect.timeoutOrElse({
406 duration: "8 seconds",
407 orElse: () => Effect.fail(new NotReady({ status: 0 })),
408 }),
409 Effect.flatMap((res) =>
410 res.status === 200
411 ? res.json.pipe(
412 Effect.mapError(() => new NotReady({ status: res.status })),
413 )
414 : Effect.fail(new NotReady({ status: res.status })),
415 ),
416 Effect.retry({
417 while: (e) =>
418 e._tag === "NotReady" &&
419 (e.status === 0 ||
420 e.status === 404 ||
421 e.status === 502 ||
422 e.status === 503),
423 schedule: Schedule.exponential("500 millis").pipe(
424 Schedule.upTo({ duration: "45 seconds" }),
425 ),
426 times: 10,
427 }),
428 );
429
430 const getText = client.get(out.url!).pipe(
431 Effect.timeoutOrElse({
432 duration: "8 seconds",
433 orElse: () => Effect.fail(new NotReady({ status: 0 })),
434 }),
435 Effect.flatMap((res) =>
436 res.status === 200
437 ? res.text.pipe(
438 Effect.mapError(() => new NotReady({ status: res.status })),
439 )
440 : Effect.fail(new NotReady({ status: res.status })),
441 ),
442 Effect.retry({
443 while: (e) =>
444 e._tag === "NotReady" &&
445 (e.status === 0 ||
446 e.status === 404 ||
447 e.status === 502 ||
448 e.status === 503),
449 schedule: Schedule.exponential("500 millis").pipe(
450 Schedule.upTo({ duration: "45 seconds" }),
451 ),
452 times: 10,
453 }),
454 );
455
456 if (out.mode === "effect") {
457 const ping = (yield* get("/ping")) as { ok?: boolean };
458 expect(ping.ok).toEqual(true);
459
460 const health = (yield* get("/health")) as { rows?: unknown };
461 expect(firstOk(health.rows)).toEqual(1);
462 } else {
463 const body = yield* getText;
464 expect(typeof body).toEqual("string");
465 expect(body.length).toBeGreaterThan(0);
466 }
467
468 const rows = yield* selectOne(out.publicConnectionUri);
469 expect(firstOk(rows)).toEqual(1);
470 }).pipe(logLevel),
471 { timeout: 120_000 },
472 );
473 },
474);