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 MongoApi, { Db, Site } from "./fixtures/mongo-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 ping = (url: string) =>
40 Railway.pingMongo(url).pipe(
41 // Allow the new database and TCP proxy to become ready. Ten retries
42 // bound backoff to 50 seconds within the test's overall timeout.
43 Effect.retry({ schedule: Schedule.spaced("5 seconds"), times: 10 }),
44 );
45
46const readVariables = Query.fn(
47 (projectId: string, environmentId: string, serviceId: string) =>
48 RailwayApi.variables({
49 projectId,
50 environmentId,
51 serviceId,
52 unrendered: true,
53 }),
54);
55
56const readServiceDeletedAt = Query.fn((id: string) => ({
57 deletedAt: RailwayApi.service({ id }).deletedAt,
58}));
59
60const readService = Query.fn((id: string) => {
61 const service = RailwayApi.service({ id });
62 return {
63 id: service.id,
64 name: service.name,
65 projectId: service.projectId,
66 deletedAt: service.deletedAt,
67 };
68});
69
70const readServiceInstance = Query.fn(
71 (environmentId: string, serviceId: string) => {
72 const instance = RailwayApi.serviceInstance({ environmentId, serviceId });
73 return {
74 serviceId: instance.serviceId,
75 image: instance.source.image,
76 startCommand: instance.startCommand,
77 };
78 },
79);
80
81const readVolumeInstance = Query.fn((id: string) => {
82 const volume = RailwayApi.volumeInstance({ id });
83 return {
84 id: volume.id,
85 volumeId: volume.volumeId,
86 mountPath: volume.mountPath,
87 serviceId: volume.serviceId,
88 };
89});
90
91const readTcpProxies = Query.fn((environmentId: string, serviceId: string) =>
92 RailwayApi.tcpProxies({ environmentId, serviceId }).pipe(
93 Query.map((proxy) => ({
94 id: proxy.id,
95 domain: proxy.domain,
96 proxyPort: proxy.proxyPort,
97 applicationPort: proxy.applicationPort,
98 deletedAt: proxy.deletedAt,
99 syncStatus: proxy.syncStatus,
100 })),
101 ),
102);
103
104const asVariableMap = (value: unknown): Record<string, string> => {
105 if (value === null || typeof value !== "object" || Array.isArray(value)) {
106 return {};
107 }
108 const out: Record<string, string> = {};
109 for (const [key, item] of Object.entries(value)) {
110 if (typeof item === "string") {
111 out[key] = item;
112 }
113 }
114 return out;
115};
116
117const readServiceVariables = (
118 projectId: string,
119 environmentId: string,
120 serviceId: string,
121) =>
122 readVariables(projectId, environmentId, serviceId).pipe(
123 Effect.map(asVariableMap),
124 Effect.catchTag("RailwayNotFound", () =>
125 Effect.succeed({} as Record<string, string>),
126 ),
127 );
128
129const waitUntilServiceGone = (serviceId: string) =>
130 readServiceDeletedAt(serviceId).pipe(
131 Effect.map((service) =>
132 service.deletedAt != null ? ("gone" as const) : ("found" as const),
133 ),
134 Effect.catchTag("RailwayNotFound", () => Effect.succeed("gone" as const)),
135 Effect.repeat({
136 schedule: Schedule.spaced("1 second"),
137 until: (status) => status === "gone",
138 times: 10,
139 }),
140 );
141
142class NotReady extends Data.TaggedError("NotReady")<{
143 status: number;
144 body?: unknown;
145}> {
146 override get message() {
147 return this.body === undefined
148 ? `status ${this.status}`
149 : `status ${this.status}: ${JSON.stringify(this.body)}`;
150 }
151}
152
153const FixtureStack = Alchemy.Stack(
154 "RailwayMongoFixture",
155 {
156 providers: Railway.providers(),
157 state: Alchemy.localState(),
158 },
159 Effect.gen(function* () {
160 const project = yield* Site;
161 const db = yield* Db;
162 const api = yield* MongoApi;
163 return {
164 projectId: project.projectId,
165 environmentId: db.environmentId,
166 serviceId: api.serviceId,
167 url: api.url,
168 publicConnectionUri: db.publicConnectionUri,
169 mode: "effect" as const,
170 };
171 }),
172);
173
174test.provider(
175 "create, ping, update, list, and delete mongo",
176 (stack) =>
177 Effect.gen(function* () {
178 yield* stack.destroy();
179
180 const created = yield* stack.deploy(
181 Effect.gen(function* () {
182 const { project, environment } = yield* suitePartition;
183 const db = yield* Railway.mongo("Db", { project, environment });
184 return { project, environment, db };
185 }),
186 );
187
188 expect(created.db.serviceId).toEqual(expect.any(String));
189 expect(created.db.serviceId.length).toBeGreaterThan(0);
190 expect(created.db.projectId).toEqual(created.project.projectId);
191 expect(created.db.environmentId).toEqual(
192 created.environment.environmentId,
193 );
194 expect(created.db.name).toEqual(expect.any(String));
195 expect(created.db.name.length).toBeGreaterThan(0);
196 expect(created.db.name.length).toBeLessThanOrEqual(32);
197 expect(created.db.name).toMatch(/^[a-z][a-z0-9-]*$/);
198 expect(created.db.user).toEqual("mongo");
199 expect(created.db.database).toEqual("railway");
200 expect(created.db.volumeId.length).toBeGreaterThan(0);
201 expect(created.db.volumeInstanceId.length).toBeGreaterThan(0);
202 expect(created.db.tcpProxyId).toEqual(expect.any(String));
203 expect(created.db.tcpProxyDomain).toEqual(expect.any(String));
204 expect(created.db.tcpProxyDomain).toContain("proxy.rlwy.net");
205 expect(created.db.tcpProxyPort).toEqual(expect.any(Number));
206 expect(created.db.tcpProxyPort).toBeGreaterThan(0);
207 expect(
208 created.db.connectionUri.includes(
209 `${created.db.name}.railway.internal`,
210 ),
211 ).toEqual(true);
212 expect(
213 created.db.publicConnectionUri.includes(created.db.tcpProxyDomain!),
214 ).toEqual(true);
215
216 const fetched = yield* readService(created.db.serviceId);
217 expect(fetched.id).toEqual(created.db.serviceId);
218 expect(fetched.name).toEqual(created.db.name);
219 expect(fetched.projectId).toEqual(created.db.projectId);
220 expect(fetched.deletedAt).toBeNull();
221
222 const instance = yield* readServiceInstance(
223 created.db.environmentId,
224 created.db.serviceId,
225 );
226 expect(instance.serviceId).toEqual(created.db.serviceId);
227 expect(instance.image).toEqual(expect.stringContaining("mongo"));
228 expect(instance.startCommand ?? "").toContain("--ipv6");
229 expect(instance.startCommand ?? "").toContain("bind_ip");
230
231 const volume = yield* readVolumeInstance(created.db.volumeInstanceId);
232 expect(volume.id).toEqual(created.db.volumeInstanceId);
233 expect(volume.volumeId).toEqual(created.db.volumeId);
234 expect(volume.mountPath).toEqual("/data/db");
235 expect(volume.serviceId).toEqual(created.db.serviceId);
236
237 const proxies = yield* readTcpProxies(
238 created.db.environmentId,
239 created.db.serviceId,
240 );
241 const liveProxy = proxies.find(
242 (proxy) => proxy.deletedAt == null && proxy.syncStatus !== "DELETED",
243 );
244 expect(liveProxy).toBeDefined();
245 expect(liveProxy?.id).toEqual(created.db.tcpProxyId);
246 expect(liveProxy?.applicationPort).toEqual(27017);
247
248 const vars = yield* readServiceVariables(
249 created.db.projectId,
250 created.db.environmentId,
251 created.db.serviceId,
252 );
253 expect(vars.MONGO_INITDB_ROOT_USERNAME).toEqual("mongo");
254 expect((vars.MONGO_INITDB_ROOT_PASSWORD ?? "").length).toBeGreaterThan(0);
255 expect(vars[Railway.MONGO_URL_SECRET] ?? "").toContain(
256 "mongodb://${{MONGO_INITDB_ROOT_USERNAME}}",
257 );
258 expect(vars.MONGOHOST).toEqual("${{RAILWAY_PRIVATE_DOMAIN}}");
259 expect(vars.MONGOPORT).toEqual("27017");
260
261 const provider = yield* Provider.findProvider(Railway.Mongo);
262 const listed = yield* provider.list();
263 const found = listed.find(
264 (row) => row.serviceId === created.db.serviceId,
265 );
266 expect(found).toBeDefined();
267 expect(found?.name).toEqual(created.db.name);
268 expect(found?.projectId).toEqual(created.db.projectId);
269
270 const pong = yield* ping(created.db.publicConnectionUri);
271 expect(pong.ok).toEqual(1);
272
273 const nextName =
274 created.db.name.slice(0, -1) +
275 (created.db.name.endsWith("z") ? "y" : "z");
276
277 const updated = yield* stack.deploy(
278 Effect.gen(function* () {
279 const { project, environment } = yield* suitePartition;
280 const db = yield* Railway.mongo("Db", {
281 project,
282 environment,
283 name: nextName,
284 });
285 return { project, environment, db };
286 }),
287 );
288
289 expect(updated.db.serviceId).toEqual(created.db.serviceId);
290 expect(updated.db.name).toEqual(nextName);
291 expect(updated.db.projectId).toEqual(created.db.projectId);
292 expect(updated.db.volumeId).toEqual(created.db.volumeId);
293 expect(
294 updated.db.connectionUri.includes(`${nextName}.railway.internal`),
295 ).toEqual(true);
296
297 const fetchedUpdate = yield* readService(updated.db.serviceId);
298 expect(fetchedUpdate.id).toEqual(updated.db.serviceId);
299 expect(fetchedUpdate.name).toEqual(nextName);
300
301 const pongUpdate = yield* ping(updated.db.publicConnectionUri);
302 expect(pongUpdate.ok).toEqual(1);
303
304 yield* stack.destroy();
305
306 const gone = yield* waitUntilServiceGone(created.db.serviceId);
307 expect(gone).toEqual("gone");
308 const volumeGone = yield* waitUntilVolumeGone(
309 created.db.volumeInstanceId,
310 );
311 expect(volumeGone).toEqual("gone");
312 }).pipe(logLevel),
313 {
314 tags: [
315 "provider:railway",
316 "provider:railway:mongo",
317 "provider:railway:project",
318 "provider:railway:projectenvironment",
319 "provider:railway:service",
320 "provider:railway:variable",
321 "live",
322 ],
323 timeout: 120_000,
324 },
325);
326
327// Runtime fixture failures must not prevent the independent resource lifecycle test.
328describe(
329 "ConnectMongo runtime integrations",
330 {
331 tags: [
332 "provider:railway",
333 "provider:railway:mongo",
334 "provider:railway:project",
335 "provider:railway:projectenvironment",
336 "provider:railway:service",
337 "provider:railway:variable",
338 "live",
339 ],
340 },
341 () => {
342 const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
343 providers: Railway.providers(),
344 });
345
346 const fixture = beforeAll(deploy(FixtureStack), {
347 timeout: 120_000,
348 });
349 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(FixtureStack), {
350 timeout: 120_000,
351 });
352
353 test(
354 "a Service connects and pings through ConnectMongo",
355 Effect.gen(function* () {
356 const out = yield* fixture;
357 expect(out.serviceId).toEqual(expect.any(String));
358 expect(out.serviceId.length).toBeGreaterThan(0);
359 expect(out.url).toEqual(expect.any(String));
360 expect(out.url).toContain("up.railway.app");
361
362 const fetched = yield* distilled(readService(out.serviceId));
363 expect(fetched.id).toEqual(out.serviceId);
364 expect(fetched.deletedAt).toBeNull();
365
366 const vars = yield* distilled(
367 readServiceVariables(out.projectId, out.environmentId, out.serviceId),
368 );
369 expect((vars[Railway.MONGO_URL_SECRET] ?? "").length).toBeGreaterThan(
370 0,
371 );
372
373 const client = yield* HttpClient.HttpClient;
374 const get = (path: string) =>
375 client.get(`${out.url}${path}`).pipe(
376 Effect.timeoutOrElse({
377 duration: "8 seconds",
378 orElse: () => Effect.fail(new NotReady({ status: 0 })),
379 }),
380 Effect.flatMap((res) =>
381 res.status === 200
382 ? res.json.pipe(
383 Effect.mapError(() => new NotReady({ status: res.status })),
384 )
385 : Effect.fail(new NotReady({ status: res.status })),
386 ),
387 Effect.retry({
388 while: (e) =>
389 e._tag === "NotReady" &&
390 (e.status === 0 ||
391 e.status === 404 ||
392 e.status === 502 ||
393 e.status === 503),
394 schedule: Schedule.exponential("500 millis").pipe(
395 Schedule.upTo({ duration: "45 seconds" }),
396 ),
397 times: 10,
398 }),
399 );
400
401 const getText = client.get(out.url!).pipe(
402 Effect.timeoutOrElse({
403 duration: "8 seconds",
404 orElse: () => Effect.fail(new NotReady({ status: 0 })),
405 }),
406 Effect.flatMap((res) =>
407 res.status === 200
408 ? res.text.pipe(
409 Effect.mapError(() => new NotReady({ status: res.status })),
410 )
411 : Effect.fail(new NotReady({ status: res.status })),
412 ),
413 Effect.retry({
414 while: (e) =>
415 e._tag === "NotReady" &&
416 (e.status === 0 ||
417 e.status === 404 ||
418 e.status === 502 ||
419 e.status === 503),
420 schedule: Schedule.exponential("500 millis").pipe(
421 Schedule.upTo({ duration: "45 seconds" }),
422 ),
423 times: 10,
424 }),
425 );
426
427 if (out.mode === "effect") {
428 const pingBody = (yield* get("/ping")) as { ok?: boolean };
429 expect(pingBody.ok).toEqual(true);
430
431 const health = (yield* get("/health")) as { ok?: number };
432 expect(health.ok).toEqual(1);
433 } else {
434 const body = yield* getText;
435 expect(typeof body).toEqual("string");
436 expect(body.length).toBeGreaterThan(0);
437 }
438
439 const pong = yield* ping(out.publicConnectionUri);
440 expect(pong.ok).toEqual(1);
441 }).pipe(logLevel),
442 { timeout: 120_000 },
443 );
444 },
445);