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 Drizzle from "@/Drizzle/Postgres.ts";
6import * as Alchemy from "@/index.ts";
7import * as Provider from "@/Provider";
8import * as Railway from "@/Railway";
9import { suitePartition } from "./suiteProject.ts";
10import { waitUntilVolumeGone } from "./waitUntilVolumeGone.ts";
11import * as Test from "@/Test/Alchemy";
12import { describe, expect } from "alchemy-test";
13import * as Data from "effect/Data";
14import * as Effect from "effect/Effect";
15import * as Layer from "effect/Layer";
16import { MinimumLogLevel } from "effect/References";
17import * as Redacted from "effect/Redacted";
18import * as Schedule from "effect/Schedule";
19import * as HttpClient from "effect/http/HttpClient";
20import { PostgresFn } from "./fixtures/async-postgres-fn.ts";
21import PostgresApi, { Db, Site } from "./fixtures/postgres-api.ts";
22
23const { test } = Test.make({
24 providers: Railway.providers(),
25});
26
27const logLevel = Effect.provideService(
28 MinimumLogLevel,
29 process.env.DEBUG ? "Debug" : "Info",
30);
31
32const distilled = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
33 effect.pipe(
34 Effect.provide(
35 Layer.merge(
36 GraphQLLive,
37 fromAuthProvider().pipe(Layer.provide(RailwayAuth)),
38 ),
39 ),
40 );
41
42const firstOk = (rows: unknown): unknown => {
43 if (!Array.isArray(rows) || rows[0] == null || typeof rows[0] !== "object") {
44 return undefined;
45 }
46 return (rows[0] as { ok?: unknown }).ok;
47};
48
49const selectOne = (url: string) =>
50 Effect.tryPromise({
51 try: async () => {
52 const pg = await import("pg");
53 const client = new pg.Client({
54 connectionString: url,
55 ssl: { rejectUnauthorized: false },
56 // pg has no default connect timeout; bound each attempt so the
57 // retry schedule (not a hung socket) owns the readiness budget.
58 connectionTimeoutMillis: 10_000,
59 });
60 await client.connect();
61 try {
62 return (await client.query("select 1 as ok")).rows;
63 } finally {
64 await client.end();
65 }
66 },
67 catch: (cause) => new Error(String(cause)),
68 // Allow the new database and TCP proxy to become ready. Ten retries
69 // bound backoff to 50 seconds; each connection attempt also has a timeout.
70 }).pipe(Effect.retry({ schedule: Schedule.spaced("5 seconds"), times: 10 }));
71
72const readVariables = Query.fn(
73 (projectId: string, environmentId: string, serviceId: string) =>
74 RailwayApi.variables({
75 projectId,
76 environmentId,
77 serviceId,
78 unrendered: true,
79 }),
80);
81
82const readServiceDeletedAt = Query.fn((id: string) => ({
83 deletedAt: RailwayApi.service({ id }).deletedAt,
84}));
85
86const readService = Query.fn((id: string) => {
87 const service = RailwayApi.service({ id });
88 return {
89 id: service.id,
90 name: service.name,
91 projectId: service.projectId,
92 deletedAt: service.deletedAt,
93 };
94});
95
96const readServiceInstance = Query.fn(
97 (environmentId: string, serviceId: string) => {
98 const instance = RailwayApi.serviceInstance({ environmentId, serviceId });
99 return {
100 serviceId: instance.serviceId,
101 image: instance.source.image,
102 };
103 },
104);
105
106const readVolumeInstance = Query.fn((id: string) => {
107 const volume = RailwayApi.volumeInstance({ id });
108 return {
109 id: volume.id,
110 volumeId: volume.volumeId,
111 mountPath: volume.mountPath,
112 serviceId: volume.serviceId,
113 };
114});
115
116const readTcpProxies = Query.fn((environmentId: string, serviceId: string) =>
117 RailwayApi.tcpProxies({ environmentId, serviceId }).pipe(
118 Query.map((proxy) => ({
119 id: proxy.id,
120 domain: proxy.domain,
121 proxyPort: proxy.proxyPort,
122 applicationPort: proxy.applicationPort,
123 deletedAt: proxy.deletedAt,
124 syncStatus: proxy.syncStatus,
125 })),
126 ),
127);
128
129const asVariableMap = (value: unknown): Record<string, string> => {
130 if (value === null || typeof value !== "object" || Array.isArray(value)) {
131 return {};
132 }
133 const out: Record<string, string> = {};
134 for (const [key, item] of Object.entries(value)) {
135 if (typeof item === "string") {
136 out[key] = item;
137 }
138 }
139 return out;
140};
141
142const readServiceVariables = (
143 projectId: string,
144 environmentId: string,
145 serviceId: string,
146) =>
147 readVariables(projectId, environmentId, serviceId).pipe(
148 Effect.map(asVariableMap),
149 Effect.catchTag("RailwayNotFound", () =>
150 Effect.succeed({} as Record<string, string>),
151 ),
152 );
153
154const waitUntilServiceGone = (serviceId: string) =>
155 readServiceDeletedAt(serviceId).pipe(
156 Effect.map((service) =>
157 service.deletedAt != null ? ("gone" as const) : ("found" as const),
158 ),
159 Effect.catchTag("RailwayNotFound", () => Effect.succeed("gone" as const)),
160 Effect.repeat({
161 schedule: Schedule.spaced("1 second"),
162 until: (status) => status === "gone",
163 times: 10,
164 }),
165 );
166
167class NotReady extends Data.TaggedError("NotReady")<{
168 status: number;
169 body?: unknown;
170}> {
171 override get message() {
172 return this.body === undefined
173 ? `status ${this.status}`
174 : `status ${this.status}: ${JSON.stringify(this.body)}`;
175 }
176}
177
178// Deploy the database first, then extend the same stack with its clients.
179// Each setup phase gets its own bounded budget; a remote image build no
180// longer shares its deadline with database and volume provisioning.
181const DatabaseFixtureStack = Alchemy.Stack(
182 "RailwayPostgresFixture",
183 {
184 providers: Railway.providers(),
185 state: Alchemy.localState(),
186 },
187 Effect.gen(function* () {
188 return yield* Db;
189 }),
190);
191
192const FixtureStack = Alchemy.Stack(
193 "RailwayPostgresFixture",
194 {
195 providers: Railway.providers(),
196 state: Alchemy.localState(),
197 },
198 Effect.gen(function* () {
199 const project = yield* Site;
200 const db = yield* Db;
201 const api = yield* PostgresApi;
202 const fn = yield* PostgresFn;
203 return {
204 projectId: project.projectId,
205 environmentId: db.environmentId,
206 serviceId: api.serviceId,
207 url: api.url,
208 publicConnectionUri: db.publicConnectionUri,
209 functionId: fn.serviceId,
210 functionUrl: fn.url,
211 mode: "effect" as const,
212 };
213 }),
214);
215
216test.provider(
217 "create, select 1, update, list, and delete postgres",
218 (stack) =>
219 Effect.gen(function* () {
220 yield* stack.destroy();
221
222 const created = yield* stack.deploy(
223 Effect.gen(function* () {
224 const { project, environment } = yield* suitePartition;
225 const db = yield* Railway.Postgres("Db", { project, environment });
226 return { project, environment, db };
227 }),
228 );
229
230 expect(created.db.serviceId).toEqual(expect.any(String));
231 expect(created.db.serviceId.length).toBeGreaterThan(0);
232 expect(created.db.projectId).toEqual(created.project.projectId);
233 expect(created.db.environmentId).toEqual(
234 created.environment.environmentId,
235 );
236 expect(created.db.name).toEqual(expect.any(String));
237 expect(created.db.name.length).toBeGreaterThan(0);
238 expect(created.db.name.length).toBeLessThanOrEqual(32);
239 expect(created.db.name).toMatch(/^[a-z][a-z0-9-]*$/);
240 expect(created.db.user).toEqual("postgres");
241 expect(created.db.database).toEqual("railway");
242 expect(created.db.volumeId.length).toBeGreaterThan(0);
243 expect(created.db.volumeInstanceId.length).toBeGreaterThan(0);
244 expect(created.db.tcpProxyId).toEqual(expect.any(String));
245 expect(created.db.tcpProxyDomain).toEqual(expect.any(String));
246 expect(created.db.tcpProxyDomain).toContain("proxy.rlwy.net");
247 expect(created.db.tcpProxyPort).toEqual(expect.any(Number));
248 expect(created.db.tcpProxyPort).toBeGreaterThan(0);
249 expect(
250 created.db.connectionUri.includes(
251 `${created.db.name}.railway.internal`,
252 ),
253 ).toEqual(true);
254 expect(
255 created.db.publicConnectionUri.includes(created.db.tcpProxyDomain!),
256 ).toEqual(true);
257
258 const fetched = yield* readService(created.db.serviceId);
259 expect(fetched.id).toEqual(created.db.serviceId);
260 expect(fetched.name).toEqual(created.db.name);
261 expect(fetched.projectId).toEqual(created.db.projectId);
262 expect(fetched.deletedAt).toBeNull();
263
264 const instance = yield* readServiceInstance(
265 created.db.environmentId,
266 created.db.serviceId,
267 );
268 expect(instance.serviceId).toEqual(created.db.serviceId);
269 expect(instance.image).toEqual(expect.stringContaining("postgres-ssl"));
270
271 const volume = yield* readVolumeInstance(created.db.volumeInstanceId);
272 expect(volume.id).toEqual(created.db.volumeInstanceId);
273 expect(volume.volumeId).toEqual(created.db.volumeId);
274 expect(volume.mountPath).toEqual("/var/lib/postgresql/data");
275 expect(volume.serviceId).toEqual(created.db.serviceId);
276
277 const proxies = yield* readTcpProxies(
278 created.db.environmentId,
279 created.db.serviceId,
280 );
281 const liveProxy = proxies.find(
282 (proxy) => proxy.deletedAt == null && proxy.syncStatus !== "DELETED",
283 );
284 expect(liveProxy).toBeDefined();
285 expect(liveProxy?.id).toEqual(created.db.tcpProxyId);
286 expect(liveProxy?.applicationPort).toEqual(5432);
287
288 const provider = yield* Provider.findProvider(Railway.Postgres);
289 const listed = yield* provider.list();
290 const found = listed.find(
291 (row) => row.serviceId === created.db.serviceId,
292 );
293 expect(found).toBeDefined();
294 expect(found?.name).toEqual(created.db.name);
295 expect(found?.projectId).toEqual(created.db.projectId);
296
297 const rows = yield* selectOne(created.db.publicConnectionUri);
298 expect(firstOk(rows)).toEqual(1);
299
300 const nextName =
301 created.db.name.slice(0, -1) +
302 (created.db.name.endsWith("z") ? "y" : "z");
303
304 const updated = yield* stack.deploy(
305 Effect.gen(function* () {
306 const { project, environment } = yield* suitePartition;
307 const db = yield* Railway.Postgres("Db", {
308 project,
309 environment,
310 name: nextName,
311 });
312 return { project, environment, db };
313 }),
314 );
315
316 expect(updated.db.serviceId).toEqual(created.db.serviceId);
317 expect(updated.db.name).toEqual(nextName);
318 expect(updated.db.projectId).toEqual(created.db.projectId);
319 expect(updated.db.volumeId).toEqual(created.db.volumeId);
320 expect(
321 updated.db.connectionUri.includes(`${nextName}.railway.internal`),
322 ).toEqual(true);
323
324 const fetchedUpdate = yield* readService(updated.db.serviceId);
325 expect(fetchedUpdate.id).toEqual(updated.db.serviceId);
326 expect(fetchedUpdate.name).toEqual(nextName);
327
328 yield* stack.destroy();
329
330 const gone = yield* waitUntilServiceGone(created.db.serviceId);
331 expect(gone).toEqual("gone");
332 const volumeGone = yield* waitUntilVolumeGone(
333 created.db.volumeInstanceId,
334 );
335 expect(volumeGone).toEqual("gone");
336 }).pipe(logLevel),
337 {
338 tags: [
339 "provider:railway",
340 "provider:railway:postgres",
341 "provider:railway:project",
342 "provider:railway:projectenvironment",
343 "provider:railway:service",
344 "live",
345 ],
346 timeout: 120_000,
347 },
348);
349
350// Runtime fixture failures must not prevent the independent resource lifecycle test.
351describe(
352 "ConnectPostgres runtime integrations",
353 {
354 tags: [
355 "provider:railway",
356 "provider:railway:function",
357 "provider:railway:postgres",
358 "provider:railway:project",
359 "provider:railway:projectenvironment",
360 "provider:railway:service",
361 "provider:railway:variable",
362 "live",
363 ],
364 },
365 () => {
366 const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
367 providers: Railway.providers(),
368 });
369
370 beforeAll(deploy(DatabaseFixtureStack), { timeout: 120_000 });
371 const fixture = beforeAll(deploy(FixtureStack), {
372 timeout: 120_000,
373 });
374 afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(FixtureStack), {
375 timeout: 120_000,
376 });
377
378 test(
379 "a Service connects and SELECTs through ConnectPostgres",
380 Effect.gen(function* () {
381 const out = yield* fixture;
382 expect(out.serviceId).toEqual(expect.any(String));
383 expect(out.serviceId.length).toBeGreaterThan(0);
384 expect(out.url).toEqual(expect.any(String));
385 expect(out.url).toContain("up.railway.app");
386
387 const fetched = yield* distilled(readService(out.serviceId));
388 expect(fetched.id).toEqual(out.serviceId);
389 expect(fetched.deletedAt).toBeNull();
390
391 const vars = yield* distilled(
392 readServiceVariables(out.projectId, out.environmentId, out.serviceId),
393 );
394 expect(
395 (vars[Railway.DATABASE_URL_SECRET] ?? "").length,
396 ).toBeGreaterThan(0);
397
398 const client = yield* HttpClient.HttpClient;
399 const get = (path: string) =>
400 client.get(`${out.url}${path}`).pipe(
401 Effect.timeoutOrElse({
402 duration: "8 seconds",
403 orElse: () => Effect.fail(new NotReady({ status: 0 })),
404 }),
405 Effect.flatMap((res) =>
406 res.status === 200
407 ? res.json.pipe(
408 Effect.mapError(() => new NotReady({ status: res.status })),
409 )
410 : Effect.fail(new NotReady({ status: res.status })),
411 ),
412 Effect.retry({
413 while: (e) =>
414 e._tag === "NotReady" &&
415 (e.status === 0 ||
416 e.status === 404 ||
417 e.status === 502 ||
418 e.status === 503),
419 schedule: Schedule.exponential("500 millis").pipe(
420 Schedule.upTo({ duration: "45 seconds" }),
421 ),
422 times: 10,
423 }),
424 );
425
426 const getText = client.get(out.url!).pipe(
427 Effect.timeoutOrElse({
428 duration: "8 seconds",
429 orElse: () => Effect.fail(new NotReady({ status: 0 })),
430 }),
431 Effect.flatMap((res) =>
432 res.status === 200
433 ? res.text.pipe(
434 Effect.mapError(() => new NotReady({ status: res.status })),
435 )
436 : Effect.fail(new NotReady({ status: res.status })),
437 ),
438 Effect.retry({
439 while: (e) =>
440 e._tag === "NotReady" &&
441 (e.status === 0 ||
442 e.status === 404 ||
443 e.status === 502 ||
444 e.status === 503),
445 schedule: Schedule.exponential("500 millis").pipe(
446 Schedule.upTo({ duration: "45 seconds" }),
447 ),
448 times: 10,
449 }),
450 );
451
452 if (out.mode === "effect") {
453 const ping = (yield* get("/ping")) as { ok?: boolean };
454 expect(ping.ok).toEqual(true);
455
456 const health = (yield* get("/health")) as { rows?: unknown };
457 expect(firstOk(health.rows)).toEqual(1);
458 } else {
459 // Public image fallback: docker push is impossible without a
460 // registry. HTTP health is hashicorp/http-echo; SELECT 1 runs
461 // over the public TCP proxy. ConnectPostgres still packed
462 // DATABASE_URL onto the Service.
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 test(
475 "an async Function SELECTs through env.DATABASE_URL",
476 Effect.gen(function* () {
477 const out = yield* fixture;
478 expect(out.functionId).toEqual(expect.any(String));
479 expect(out.functionUrl).toEqual(expect.any(String));
480 expect(out.functionUrl).toContain("up.railway.app");
481
482 const vars = yield* distilled(
483 readServiceVariables(
484 out.projectId,
485 out.environmentId,
486 out.functionId,
487 ),
488 );
489 expect(
490 (vars[Railway.DATABASE_URL_SECRET] ?? "").length,
491 ).toBeGreaterThan(0);
492
493 const client = yield* HttpClient.HttpClient;
494 const get = (path: string) =>
495 client.get(`${out.functionUrl}${path}`).pipe(
496 Effect.timeoutOrElse({
497 duration: "8 seconds",
498 orElse: () => Effect.fail(new NotReady({ status: 0 })),
499 }),
500 Effect.flatMap((res) =>
501 res.status === 200
502 ? res.json.pipe(
503 Effect.mapError(() => new NotReady({ status: res.status })),
504 )
505 : res.text.pipe(
506 Effect.catch(() => Effect.succeed("")),
507 Effect.flatMap((body) =>
508 Effect.fail(new NotReady({ status: res.status, body })),
509 ),
510 ),
511 ),
512 Effect.retry({
513 while: (e) =>
514 e._tag === "NotReady" &&
515 (e.status === 0 ||
516 e.status === 404 ||
517 e.status === 502 ||
518 e.status === 503),
519 schedule: Schedule.exponential("500 millis").pipe(
520 Schedule.upTo({ duration: "45 seconds" }),
521 ),
522 times: 10,
523 }),
524 );
525
526 const health = (yield* get("/")) as { rows?: unknown };
527 expect(firstOk(health.rows)).toEqual(1);
528 }).pipe(logLevel),
529 { timeout: 120_000 },
530 );
531 },
532);