1import {
2 environmentServiceInstances,
3 waitUntilDeleted,
4 projectServices,
5 environmentVolumes,
6} from "./GraphQL.ts";
7import { randomBytes } from "node:crypto";
8import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
9import {
10 Railway,
11 type ServiceCreateInput,
12 type ServiceInstance,
13 type ServiceInstanceUpdateInput,
14 type Service as RailwayService,
15 type TCPProxy,
16 type TCPProxyCreateInput,
17 type VariableUpsertInput,
18 type VolumeCreateInput,
19 type VolumeInstance,
20 type VolumeInstanceUpdateInput,
21 type VolumeState,
22} from "@distilled.cloud/railway";
23import * as Data from "effect/Data";
24import * as Effect from "effect/Effect";
25import * as Redacted from "effect/Redacted";
26import * as Schedule from "effect/Schedule";
27import * as Stream from "effect/Stream";
28import { Unowned } from "../AdoptPolicy.ts";
29import { isResolved } from "../Diff.ts";
30import * as Provider from "../Provider.ts";
31import { Resource } from "../Resource.ts";
32import { createRailwayName, matchesAlchemyPhysicalName } from "./Metadata.ts";
33import { ownedProjects, type Project } from "./Project.ts";
34import type { Providers } from "./Providers.ts";
35
36/**
37 * A resource-valued prop: the resource itself, or an Effect that produces
38 * it (so `yield* Project(...)` and `Project(...)` both type-check).
39 */
40type Ref<T> = T | Effect.Effect<T, never, Providers>;
41
42export const DEFAULT_POSTGRES_IMAGE =
43 "ghcr.io/railwayapp-templates/postgres-ssl:16";
44export const DEFAULT_POSTGRES_USER = "postgres";
45export const DEFAULT_POSTGRES_DATABASE = "railway";
46export const POSTGRES_PORT = 5432;
47export const POSTGRES_MOUNT_PATH = "/var/lib/postgresql/data";
48export const POSTGRES_PGDATA = "/var/lib/postgresql/data/pgdata";
49import {
50 DATABASE_PUBLIC_URL_SECRET,
51 DATABASE_URL_SECRET,
52} from "./ConnectPostgres.ts";
53
54const serviceFields = <E>(service: Query<RailwayService, E>) => ({
55 id: service.id,
56 name: service.name,
57 deletedAt: service.deletedAt,
58});
59const attributeInstanceFields = <E>(instance: Query<ServiceInstance, E>) => ({
60 source: instance.source.pipe(
61 Query.map((source) => ({ image: source.image })),
62 ),
63 region: instance.region,
64 latestDeployment: instance.latestDeployment.pipe(
65 Query.map((deployment) => ({
66 id: deployment.id,
67 status: deployment.status,
68 })),
69 ),
70});
71const instanceFields = <E>(instance: Query<ServiceInstance, E>) => ({
72 ...attributeInstanceFields(instance),
73 deletedAt: instance.deletedAt,
74 sleepApplication: instance.sleepApplication,
75});
76const volumeFields = <E>(instance: Query<VolumeInstance, E>) => ({
77 id: instance.id,
78 volumeId: instance.volumeId,
79 environmentId: instance.environmentId,
80 serviceId: instance.serviceId,
81 deletedAt: instance.deletedAt,
82 isPendingDeletion: instance.isPendingDeletion,
83 state: instance.state,
84 mountPath: instance.mountPath,
85 volume: { id: instance.volume.id, name: instance.volume.name },
86});
87const proxyFields = <E>(proxy: Query<TCPProxy, E>) => ({
88 id: proxy.id,
89 applicationPort: proxy.applicationPort,
90 deletedAt: proxy.deletedAt,
91 syncStatus: proxy.syncStatus,
92 domain: proxy.domain,
93 proxyPort: proxy.proxyPort,
94});
95type CloudService = UnwrapPlan<ReturnType<typeof serviceFields>>;
96type AttributeInstance = UnwrapPlan<ReturnType<typeof attributeInstanceFields>>;
97type ServiceInstanceResponse = UnwrapPlan<ReturnType<typeof instanceFields>>;
98type CloudInstance = UnwrapPlan<ReturnType<typeof volumeFields>>;
99type CloudProxy = UnwrapPlan<ReturnType<typeof proxyFields>>;
100
101export { DATABASE_PUBLIC_URL_SECRET, DATABASE_URL_SECRET };
102
103/**
104 * Environment identity Postgres is deployed into. Accepts a
105 * `Railway.Project` (its primary environment), a `Railway.Environment`,
106 * or an `{ environmentId }` stub.
107 */
108export type PostgresEnvironment = {
109 readonly environmentId: string;
110};
111
112export interface PostgresProps {
113 /**
114 * Parent Railway Project. Accepts a `Railway.Project` or an Effect
115 * that produces one. Changing the Project replaces Postgres.
116 */
117 project: Ref<Project>;
118 /**
119 * Environment to deploy into. Accepts a `Railway.Project` (primary
120 * environment), a `Railway.Environment`, or `{ environmentId }`.
121 * Defaults to the project's primary environment. Changing it replaces
122 * Postgres.
123 */
124 environment?: Ref<PostgresEnvironment>;
125 /**
126 * Service name. Unique per Project. If omitted, a unique name is
127 * generated from the stack, stage and logical ID. Used as the private
128 * hostname `{name}.railway.internal`. Changing it updates in place.
129 */
130 name?: string;
131 /**
132 * Postgres image. Default is the official SSL image tag 16.
133 *
134 * @default "ghcr.io/railwayapp-templates/postgres-ssl:16"
135 */
136 image?: string;
137 /**
138 * Region for the service instance and volume (`us-west2`, `us-east4`,
139 * …). If omitted, Railway picks the default. Changing it replaces
140 * Postgres.
141 */
142 region?: string;
143 /**
144 * Superuser name. Create-only (used at `initdb`).
145 *
146 * @default "postgres"
147 */
148 user?: string;
149 /**
150 * Superuser password. Wrap with `Redacted.make(...)`. If omitted, a
151 * password is generated on first create and stored as
152 * `POSTGRES_PASSWORD`. Create-only.
153 */
154 password?: Redacted.Redacted<string> | string;
155 /**
156 * Initial database name. Create-only.
157 *
158 * @default "railway"
159 */
160 database?: string;
161 /**
162 * Expose Postgres on a public TCP proxy (`*.proxy.rlwy.net`) for
163 * laptop access and deploy-time migrations. In-service connections
164 * always use `{name}.railway.internal`.
165 *
166 * @default true
167 */
168 public?: boolean;
169}
170
171export type Postgres = Resource<
172 "Railway.Postgres",
173 PostgresProps,
174 {
175 /** Railway service id for the Postgres container. */
176 serviceId: string;
177 /** Physical service name. Private hostname is `{name}.railway.internal`. */
178 name: string;
179 /** Parent Railway project id. */
180 projectId: string;
181 /** Environment the instance is deployed in. */
182 environmentId: string;
183 /** Observed `source.image`. */
184 image: string | undefined;
185 /** Observed region, if Railway reported one. */
186 region: string | undefined;
187 /** Volume id holding PGDATA. */
188 volumeId: string;
189 /** Volume instance id in the target environment. */
190 volumeInstanceId: string;
191 /** TCP proxy id, when `public` is enabled. */
192 tcpProxyId: string | undefined;
193 /** Public proxy hostname (`*.proxy.rlwy.net`). */
194 tcpProxyDomain: string | undefined;
195 /** Public proxy port. Pair with `tcpProxyDomain`. */
196 tcpProxyPort: number | undefined;
197 /** Superuser name. */
198 user: string;
199 /** Database name. */
200 database: string;
201 /**
202 * Private Postgres URI (`{name}.railway.internal:5432`). Prefer
203 * {@link ConnectPostgres} from a {@link Service}.
204 */
205 connectionUri: string;
206 /**
207 * Public TCP-proxy URI. Empty when `public` is false. Use this from
208 * the laptop / deploy-time migrations.
209 */
210 publicConnectionUri: string;
211 /** Latest deployment id, if one exists. */
212 deploymentId: string | undefined;
213 /** Latest deployment status (`SUCCESS`, `DEPLOYING`, …). */
214 deploymentStatus: string | undefined;
215 },
216 never,
217 Providers
218>;
219
220const resolvePostgresProps = (
221 props: PostgresProps | Effect.Effect<PostgresProps, never, Providers>,
222): Effect.Effect<PostgresProps, never, Providers> =>
223 Effect.gen(function* () {
224 const resolved = Effect.isEffect(props) ? yield* props : props;
225 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
226 const project = Effect.isEffect(resolved.project)
227 ? yield* resolved.project as Effect.Effect<Project, never, Providers>
228 : resolved.project;
229 const environment =
230 resolved.environment === undefined
231 ? undefined
232 : Effect.isEffect(resolved.environment)
233 ? yield* resolved.environment as Effect.Effect<
234 PostgresEnvironment,
235 never,
236 Providers
237 >
238 : resolved.environment;
239 return { ...resolved, project, environment };
240 });
241
242const PostgresResource = Resource<Postgres>("Railway.Postgres");
243
244/**
245 * A Railway.Postgres is a Postgres-as-a-Service: the official
246 * `ghcr.io/railwayapp-templates/postgres-ssl:16` image, a Volume at
247 * `/var/lib/postgresql/data`, `POSTGRES_*` / `DATABASE_URL` variables,
248 * and an optional TCP proxy for the public URL.
249 *
250 * Private hostname is `{name}.railway.internal`. From a
251 * {@link Service}, yield {@link ConnectPostgres}. From a laptop, use
252 * `publicConnectionUri`.
253 *
254 * @see https://docs.railway.com/databases/build-a-database-service
255 *
256 * ### Create Postgres
257 * Pass a Project. Alchemy generates a unique name, password, volume,
258 * and a public TCP proxy.
259 *
260 * **Example:** Generated name
261 * ```typescript
262 * const site = yield* Railway.Project("Site");
263 * const db = yield* Railway.Postgres("Db", { project: site });
264 * ```
265 *
266 * :::caution[Changing `project` replaces Postgres]
267 * A new service + volume are created in the new Project. The old
268 * service and volume are deleted. Data is not copied.
269 * :::
270 *
271 * ### Connect from a Service
272 * Yield `ConnectPostgres` inside init. Provide
273 * {@link ConnectPostgresHttp}. Pass `conn.connectionString` to
274 * `Drizzle.Postgres` or `SQL.Postgres`. The binding packs the private
275 * URI (`{name}.railway.internal`).
276 *
277 * **Example:** Bind and query
278 * ```typescript
279 * import * as Drizzle from "alchemy/Drizzle/Postgres";
280 * import * as HttpServerResponse from "effect/http/HttpServerResponse";
281 *
282 * export default class Api extends Railway.Service<Api>()(
283 * "Api",
284 * { project: Site, main: import.meta.url, build: { install: ["pg"] } },
285 * Effect.gen(function* () {
286 * const conn = yield* Railway.ConnectPostgres(Db);
287 * const db = yield* Drizzle.Postgres(conn.connectionString);
288 * return {
289 * fetch: Effect.gen(function* () {
290 * const rows = yield* db.execute("select 1 as ok");
291 * return HttpServerResponse.json({ rows });
292 * }),
293 * };
294 * }).pipe(Effect.provide(Railway.ConnectPostgresHttp)),
295 * ) {}
296 * ```
297 *
298 * ### Public TCP
299 * `public` (default `true`) creates a TCP proxy on 5432.
300 * `publicConnectionUri` is `{domain}:{proxyPort}` for laptop access
301 * and deploy-time migrations.
302 *
303 * **Example:** Private only
304 * ```typescript
305 * const db = yield* Railway.Postgres("Db", {
306 * project: site,
307 * public: false,
308 * });
309 * ```
310 *
311 * ### Image
312 * Default is Postgres 16 with SSL. Pass `image` to pin another tag.
313 *
314 * **Example:** Postgres 17
315 * ```typescript
316 * const db = yield* Railway.Postgres("Db", {
317 * project: site,
318 * image: "ghcr.io/railwayapp-templates/postgres-ssl:17",
319 * });
320 * ```
321 *
322 * ### Module-scope declarations
323 * Resource-valued props accept the resource or an Effect producing it.
324 *
325 * **Example:** Module-scope Postgres
326 * ```typescript
327 * // src/db.ts
328 * import * as Railway from "alchemy/Railway";
329 *
330 * export const Site = Railway.Project("Site");
331 * export const Db = Railway.Postgres("Db", { project: Site });
332 * ```
333 *
334 * @resource
335 * @product Postgres
336 */
337export const Postgres: typeof PostgresResource = Object.assign(
338 (
339 id: string,
340 props: PostgresProps | Effect.Effect<PostgresProps, never, Providers>,
341 ) => PostgresResource(id, resolvePostgresProps(props)),
342 PostgresResource,
343);
344
345export class PostgresNotCreated extends Data.TaggedError(
346 "Railway.PostgresNotCreated",
347)<{
348 name: string;
349 projectId: string;
350}> {}
351
352export class PostgresProjectRequired extends Data.TaggedError(
353 "Railway.PostgresProjectRequired",
354)<{
355 message: string;
356}> {}
357
358export class PostgresDeployFailed extends Data.TaggedError(
359 "Railway.PostgresDeployFailed",
360)<{
361 serviceId: string;
362 status: string;
363 deploymentId: string | undefined;
364}> {}
365
366export class PostgresVolumeNotCreated extends Data.TaggedError(
367 "Railway.PostgresVolumeNotCreated",
368)<{
369 name: string;
370 serviceId: string;
371}> {}
372
373class PostgresPending extends Data.TaggedError("Railway.PostgresPending")<{
374 serviceId: string;
375 status: string;
376}> {}
377
378class PostgresDeployPending extends Data.TaggedError(
379 "Railway.PostgresDeployPending",
380)<{
381 serviceId: string;
382 status: string;
383}> {}
384
385class VolumePending extends Data.TaggedError("Railway.PostgresVolumePending")<{
386 volumeId: string;
387 state: string;
388}> {
389 override get message() {
390 return `Postgres volume ${this.volumeId} is still ${this.state}`;
391 }
392}
393
394const projectIdOf = (value: unknown): string | undefined => {
395 if (value === null || typeof value !== "object") return undefined;
396 const rec = value as { projectId?: unknown };
397 return typeof rec.projectId === "string" && rec.projectId.length > 0
398 ? rec.projectId
399 : undefined;
400};
401
402const environmentIdOf = (value: unknown): string | undefined => {
403 if (value === null || typeof value !== "object") return undefined;
404 const rec = value as { environmentId?: unknown };
405 return typeof rec.environmentId === "string" && rec.environmentId.length > 0
406 ? rec.environmentId
407 : undefined;
408};
409
410const unwrapSecret = (value: Redacted.Redacted<string> | string): string =>
411 Redacted.isRedacted(value) ? Redacted.value(value) : value;
412
413const generatePassword = Effect.sync(() => {
414 const bytes = randomBytes(16);
415 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
416});
417
418const isGoneService = (service: CloudService | undefined) =>
419 service === undefined || service.deletedAt != null;
420
421const isGoneInstance = (instance: ServiceInstanceResponse | undefined) =>
422 instance === undefined || instance.deletedAt != null;
423
424const goneVolumeState = (state: VolumeState | null | undefined) =>
425 state === "DELETED" || state === "DELETING";
426
427const transientVolumeState = (state: VolumeState | null | undefined) =>
428 state === "UPDATING" ||
429 state === "MIGRATING" ||
430 state === "MIGRATION_PENDING" ||
431 state === "RESTORING";
432
433const isGoneVolume = (instance: CloudInstance | undefined) =>
434 instance === undefined ||
435 instance.deletedAt != null ||
436 instance.isPendingDeletion ||
437 goneVolumeState(instance.state);
438
439const isGoneProxy = (proxy: CloudProxy | undefined) =>
440 proxy === undefined ||
441 proxy.deletedAt != null ||
442 proxy.syncStatus === "DELETED";
443
444const normalizeDomain = (domain: string) => domain.replace(/\.+$/, "");
445
446const sameImage = (observed: string | null | undefined, desired: string) => {
447 if (observed == null || observed.length === 0) return false;
448 if (observed === desired) return true;
449 if (observed === `${desired}:latest` || desired === `${observed}:latest`) {
450 return true;
451 }
452 return (
453 observed.endsWith(`/${desired}`) || observed.endsWith(`/${desired}:latest`)
454 );
455};
456
457const isPostgresImage = (image: string | null | undefined) =>
458 image != null && /postgres/i.test(image);
459
460const deployReady = (status: string | undefined) => status === "SUCCESS";
461
462const deployFailed = (status: string | undefined) =>
463 status === "FAILED" || status === "CRASHED" || status === "REMOVED";
464
465const resolveName = (id: string, name: string | undefined, existing?: string) =>
466 Effect.gen(function* () {
467 if (name !== undefined) return name;
468 if (existing !== undefined) return existing;
469 return yield* createRailwayName(id);
470 });
471
472const encodePart = (value: string) => encodeURIComponent(value);
473
474export const privateConnectionUri = (input: {
475 user: string;
476 password: string;
477 name: string;
478 database: string;
479}): string =>
480 `postgresql://${encodePart(input.user)}:${encodePart(input.password)}@${input.name}.railway.internal:${POSTGRES_PORT}/${encodePart(input.database)}?sslmode=no-verify`;
481
482export const publicConnectionUri = (input: {
483 user: string;
484 password: string;
485 domain: string;
486 port: number;
487 database: string;
488}): string =>
489 `postgresql://${encodePart(input.user)}:${encodePart(input.password)}@${input.domain}:${input.port}/${encodePart(input.database)}?sslmode=no-verify`;
490
491const desiredVariables = (input: {
492 user: string;
493 password: string;
494 database: string;
495}): Record<string, string> => ({
496 POSTGRES_USER: input.user,
497 POSTGRES_PASSWORD: input.password,
498 POSTGRES_DB: input.database,
499 PGDATA: POSTGRES_PGDATA,
500 PGUSER: "${{POSTGRES_USER}}",
501 PGPASSWORD: "${{POSTGRES_PASSWORD}}",
502 PGDATABASE: "${{POSTGRES_DB}}",
503 PGHOST: "${{RAILWAY_PRIVATE_DOMAIN}}",
504 PGPORT: String(POSTGRES_PORT),
505 [DATABASE_URL_SECRET]:
506 "postgresql://${{POSTGRES_USER}}:${{POSTGRES_PASSWORD}}@${{RAILWAY_PRIVATE_DOMAIN}}:5432/${{POSTGRES_DB}}",
507 [DATABASE_PUBLIC_URL_SECRET]:
508 "postgresql://${{POSTGRES_USER}}:${{POSTGRES_PASSWORD}}@${{RAILWAY_TCP_PROXY_DOMAIN}}:${{RAILWAY_TCP_PROXY_PORT}}/${{POSTGRES_DB}}",
509});
510
511const toAttrs = (input: {
512 service: CloudService;
513 instance: AttributeInstance | undefined;
514 volume: CloudInstance | undefined;
515 proxy: CloudProxy | undefined;
516 projectId: string;
517 environmentId: string;
518 user: string;
519 password: string;
520 database: string;
521}): Postgres["Attributes"] => {
522 const name = input.service.name;
523 const domain =
524 input.proxy !== undefined ? normalizeDomain(input.proxy.domain) : undefined;
525 const proxyPort = input.proxy?.proxyPort;
526 return {
527 serviceId: input.service.id,
528 name,
529 projectId: input.projectId,
530 environmentId: input.environmentId,
531 image: input.instance?.source?.image ?? undefined,
532 region: input.instance?.region ?? undefined,
533 volumeId: input.volume?.volumeId || input.volume?.volume.id || "",
534 volumeInstanceId: input.volume?.id ?? "",
535 tcpProxyId: input.proxy?.id,
536 tcpProxyDomain: domain,
537 tcpProxyPort: proxyPort,
538 user: input.user,
539 database: input.database,
540 connectionUri:
541 input.password.length > 0
542 ? privateConnectionUri({
543 user: input.user,
544 password: input.password,
545 name,
546 database: input.database,
547 })
548 : "",
549 publicConnectionUri:
550 input.password.length > 0 &&
551 domain !== undefined &&
552 domain.length > 0 &&
553 proxyPort !== undefined
554 ? publicConnectionUri({
555 user: input.user,
556 password: input.password,
557 domain,
558 port: proxyPort,
559 database: input.database,
560 })
561 : "",
562 deploymentId: input.instance?.latestDeployment?.id,
563 deploymentStatus: input.instance?.latestDeployment?.status,
564 };
565};
566
567const readService = Query.fn((id: string) =>
568 serviceFields(Railway.service({ id })),
569);
570
571const readInstance = Query.fn((environmentId: string, serviceId: string) =>
572 instanceFields(Railway.serviceInstance({ environmentId, serviceId })),
573);
574
575const readVolumeInstance = Query.fn((id: string) =>
576 volumeFields(Railway.volumeInstance({ id })),
577);
578
579const readProxies = Query.fn((environmentId: string, serviceId: string) =>
580 Railway.tcpProxies({ environmentId, serviceId }).pipe(Query.map(proxyFields)),
581);
582
583const tcpProxyDelete = Query.fn((id: string) => Railway.tcpProxyDelete({ id }));
584
585const readVariables = Query.fn(
586 (projectId: string, environmentId: string, serviceId: string) =>
587 Railway.variables({
588 projectId,
589 environmentId,
590 serviceId,
591 unrendered: true,
592 }),
593);
594
595const variableUpsert = Query.fn((input: VariableUpsertInput) =>
596 Railway.variableUpsert({ input }),
597);
598
599const volumeUpdateName = Query.fn((volumeId: string, name: string) => ({
600 id: Railway.volumeUpdate({ volumeId, input: { name } }).id,
601}));
602
603const serviceCreate = Query.fn((input: ServiceCreateInput) =>
604 serviceFields(Railway.serviceCreate({ input })),
605);
606
607const serviceUpdateName = Query.fn((id: string, name: string) =>
608 serviceFields(Railway.serviceUpdate({ id, input: { name } })),
609);
610
611const serviceInstanceUpdate = Query.fn(
612 (
613 environmentId: string,
614 serviceId: string,
615 input: ServiceInstanceUpdateInput,
616 ) => Railway.serviceInstanceUpdate({ environmentId, serviceId, input }),
617);
618
619const volumeCreate = Query.fn((input: VolumeCreateInput) => {
620 const volume = Railway.volumeCreate({ input });
621 return { id: volume.id, name: volume.name };
622});
623
624const volumeInstanceUpdate = Query.fn(
625 (volumeId: string, environmentId: string, input: VolumeInstanceUpdateInput) =>
626 Railway.volumeInstanceUpdate({ volumeId, environmentId, input }),
627);
628
629const tcpProxyCreate = Query.fn((input: TCPProxyCreateInput) =>
630 proxyFields(Railway.tcpProxyCreate({ input })),
631);
632
633const serviceInstanceDeploy = Query.fn(
634 (environmentId: string, serviceId: string) =>
635 Railway.serviceInstanceDeployV2({ environmentId, serviceId }),
636);
637
638const deploymentCancel = Query.fn((id: string) =>
639 Railway.deploymentCancel({ id }),
640);
641
642const serviceDelete = Query.fn((id: string) => Railway.serviceDelete({ id }));
643
644const volumeDelete = Query.fn((volumeId: string) =>
645 Railway.volumeDelete({ volumeId }),
646);
647
648const liveEnvironmentIds = (projectId: string) =>
649 Query.items(
650 Railway.environments({ projectId, first: 50 }).pipe(
651 Query.map((env) => ({ id: env.id, deletedAt: env.deletedAt })),
652 ),
653 );
654
655const getById = (serviceId: string) =>
656 readService(serviceId).pipe(
657 Effect.map((service) => (isGoneService(service) ? undefined : service)),
658 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
659 );
660
661const getInstance = (environmentId: string, serviceId: string) =>
662 readInstance(environmentId, serviceId).pipe(
663 Effect.map((instance) => (isGoneInstance(instance) ? undefined : instance)),
664 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
665 );
666
667const listProjectServices = (projectId: string) =>
668 projectServices(projectId, serviceFields).pipe(
669 Effect.map((services) => services.filter((node) => !isGoneService(node))),
670 Effect.catchTag("RailwayNotFound", () =>
671 Effect.succeed([] as CloudService[]),
672 ),
673 );
674
675const findByName = (projectId: string, name: string) =>
676 listProjectServices(projectId).pipe(
677 Effect.map((services) => services.find((service) => service.name === name)),
678 );
679
680const getVolumeByInstanceId = (volumeInstanceId: string) =>
681 readVolumeInstance(volumeInstanceId).pipe(
682 Effect.map((instance) => (isGoneVolume(instance) ? undefined : instance)),
683 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
684 );
685
686const listVolumeInstances = (environmentId: string, projectId: string) =>
687 environmentVolumes(environmentId, projectId, volumeFields).pipe(
688 Effect.map((instances) => instances.filter((node) => !isGoneVolume(node))),
689 Effect.catchTag("RailwayNotFound", () =>
690 Effect.succeed([] as CloudInstance[]),
691 ),
692 );
693
694const findVolume = (
695 environmentId: string,
696 projectId: string,
697 match: (instance: CloudInstance) => boolean,
698) =>
699 listVolumeInstances(environmentId, projectId).pipe(
700 Effect.map((instances) => instances.find(match)),
701 );
702
703const listProxies = (environmentId: string, serviceId: string) =>
704 readProxies(environmentId, serviceId).pipe(
705 Effect.map((items) => items.filter((proxy) => !isGoneProxy(proxy))),
706 Effect.catchTag("RailwayNotFound", () =>
707 Effect.succeed([] as CloudProxy[]),
708 ),
709 );
710
711const findProxy = (
712 environmentId: string,
713 serviceId: string,
714 applicationPort: number,
715) =>
716 listProxies(environmentId, serviceId).pipe(
717 Effect.map((items) =>
718 items.find((proxy) => proxy.applicationPort === applicationPort),
719 ),
720 );
721
722/**
723 * Delete a TCP proxy, riding out Railway's per-environment mutation lock: a
724 * delete racing an in-flight deploy fails with "Cannot delete TCP proxy: an
725 * operation is already in progress". Already-gone proxies are a no-op.
726 */
727const deleteProxy = (id: string) =>
728 tcpProxyDelete(id).pipe(
729 Effect.retry({
730 while: (e) => e._tag === "RailwayOperationInProgress",
731 schedule: Schedule.spaced("3 seconds"),
732 times: 10,
733 }),
734 Effect.catchTag("RailwayNotFound", () => Effect.void),
735 Effect.asVoid,
736 );
737
738const asVariableMap = (value: unknown): Record<string, string> => {
739 if (value === null || typeof value !== "object" || Array.isArray(value)) {
740 return {};
741 }
742 const out: Record<string, string> = {};
743 for (const [key, item] of Object.entries(value)) {
744 if (typeof item === "string") {
745 out[key] = item;
746 }
747 }
748 return out;
749};
750
751const listVariableMap = (
752 projectId: string,
753 environmentId: string,
754 serviceId: string,
755) =>
756 readVariables(projectId, environmentId, serviceId).pipe(
757 Effect.map(asVariableMap),
758 Effect.catchTag("RailwayNotFound", () =>
759 Effect.succeed({} as Record<string, string>),
760 ),
761 );
762
763const upsertVariable = (input: {
764 projectId: string;
765 environmentId: string;
766 serviceId: string;
767 name: string;
768 value: string;
769}) =>
770 variableUpsert({
771 projectId: input.projectId,
772 environmentId: input.environmentId,
773 serviceId: input.serviceId,
774 name: input.name,
775 value: input.value,
776 skipDeploys: true,
777 });
778
779const syncEnv = Effect.fn(function* (input: {
780 projectId: string;
781 environmentId: string;
782 serviceId: string;
783 desired: Record<string, string>;
784}) {
785 const observed = yield* listVariableMap(
786 input.projectId,
787 input.environmentId,
788 input.serviceId,
789 );
790 let changed = false;
791 for (const [name, value] of Object.entries(input.desired)) {
792 if (observed[name] !== value) {
793 yield* upsertVariable({
794 projectId: input.projectId,
795 environmentId: input.environmentId,
796 serviceId: input.serviceId,
797 name,
798 value,
799 });
800 changed = true;
801 }
802 }
803 return changed;
804});
805
806const waitForInstance = (environmentId: string, serviceId: string) =>
807 getInstance(environmentId, serviceId).pipe(
808 Effect.flatMap((instance) => {
809 if (instance === undefined) {
810 return Effect.fail(
811 new PostgresPending({ serviceId, status: "creating" }),
812 );
813 }
814 return Effect.succeed(instance);
815 }),
816 Effect.retry({
817 while: (e) => e._tag === "Railway.PostgresPending",
818 // serviceCreate fans the instance out to each environment
819 // asynchronously; wait a bounded interval for it to appear.
820 times: 10,
821 schedule: Schedule.spaced("2 seconds"),
822 }),
823 Effect.catchTag("Railway.PostgresPending", () =>
824 getInstance(environmentId, serviceId),
825 ),
826 );
827
828const waitForDeployment = (environmentId: string, serviceId: string) =>
829 getInstance(environmentId, serviceId).pipe(
830 Effect.flatMap((instance) => {
831 const latest = instance?.latestDeployment;
832 const status = latest?.status;
833 if (instance !== undefined && deployReady(status)) {
834 return Effect.succeed(instance);
835 }
836 return Effect.fail(
837 new PostgresDeployPending({
838 serviceId,
839 status: status ?? "pending",
840 }),
841 );
842 }),
843 Effect.retry({
844 while: (e) => e._tag === "Railway.PostgresDeployPending",
845 times: 10,
846 schedule: Schedule.spaced("5 seconds"),
847 }),
848 Effect.catchTag("Railway.PostgresDeployPending", () =>
849 getInstance(environmentId, serviceId),
850 ),
851 );
852
853const waitForVolume = (
854 environmentId: string,
855 projectId: string,
856 volumeId: string,
857 volumeInstanceId?: string,
858) => {
859 const observe =
860 volumeInstanceId !== undefined && volumeInstanceId.length > 0
861 ? getVolumeByInstanceId(volumeInstanceId)
862 : findVolume(
863 environmentId,
864 projectId,
865 (instance) => instance.volumeId === volumeId,
866 );
867 return observe.pipe(
868 Effect.flatMap((instance) => {
869 if (instance === undefined || transientVolumeState(instance.state)) {
870 return Effect.fail(
871 new VolumePending({
872 volumeId,
873 state: instance?.state ?? "creating",
874 }),
875 );
876 }
877 return Effect.succeed(instance);
878 }),
879 Effect.retry({
880 while: (e) => e._tag === "Railway.PostgresVolumePending",
881 times: 10,
882 // Volume attachment can lag creation beyond 30 seconds.
883 // Keep the wait bounded and retain the volume ID/state on exhaustion.
884 schedule: Schedule.spaced("5 seconds"),
885 }),
886 );
887};
888
889const stampVolumeName = (volumeId: string, name: string) =>
890 volumeUpdateName(volumeId, name);
891
892export const PostgresProvider = () =>
893 Provider.succeed(Postgres, {
894 stables: ["serviceId", "projectId", "environmentId", "volumeId"],
895 nuke: { dependsOn: ["Railway.Project"] },
896
897 diff: Effect.fn(function* ({ news, output }) {
898 if (news === undefined || !isResolved(news)) return undefined;
899 if (output === undefined) return undefined;
900 const nextProject = projectIdOf(news.project);
901 const projectChanged =
902 nextProject !== undefined && nextProject !== output.projectId;
903 const nextEnv = environmentIdOf(news.environment);
904 const environmentChanged =
905 nextEnv !== undefined && nextEnv !== output.environmentId;
906 const regionChanged =
907 news.region !== undefined && news.region !== output.region;
908 if (projectChanged || environmentChanged || regionChanged) {
909 return { action: "replace" as const };
910 }
911 return undefined;
912 }),
913
914 read: Effect.fn(function* ({ id, olds, output }) {
915 const projectId =
916 output?.projectId ??
917 (olds !== undefined ? projectIdOf(olds.project) : undefined);
918 const environmentId =
919 output?.environmentId ??
920 (olds !== undefined
921 ? (environmentIdOf(olds.environment) ?? environmentIdOf(olds.project))
922 : undefined);
923 const name = yield* resolveName(id, olds?.name, output?.name);
924 const byId =
925 output?.serviceId !== undefined && output.serviceId.length > 0
926 ? yield* getById(output.serviceId)
927 : undefined;
928 const found =
929 byId ??
930 (projectId !== undefined
931 ? yield* findByName(projectId, name)
932 : undefined);
933 if (found === undefined) return undefined;
934 const resolvedProjectId = projectIdOf(found) ?? projectId ?? "";
935 const resolvedEnvId =
936 environmentId ??
937 environmentIdOf(olds?.project) ??
938 output?.environmentId ??
939 "";
940 const instance =
941 resolvedEnvId.length > 0
942 ? yield* getInstance(resolvedEnvId, found.id)
943 : undefined;
944 const volume =
945 output?.volumeInstanceId !== undefined &&
946 output.volumeInstanceId.length > 0
947 ? yield* getVolumeByInstanceId(output.volumeInstanceId)
948 : resolvedEnvId.length > 0 && resolvedProjectId.length > 0
949 ? yield* findVolume(
950 resolvedEnvId,
951 resolvedProjectId,
952 (row) =>
953 (row.serviceId ?? undefined) === found.id ||
954 (output?.volumeId !== undefined &&
955 row.volumeId === output.volumeId),
956 )
957 : undefined;
958 const proxy =
959 resolvedEnvId.length > 0
960 ? yield* findProxy(resolvedEnvId, found.id, POSTGRES_PORT)
961 : undefined;
962 const vars =
963 resolvedProjectId.length > 0 && resolvedEnvId.length > 0
964 ? yield* listVariableMap(resolvedProjectId, resolvedEnvId, found.id)
965 : {};
966 const attrs = toAttrs({
967 service: found,
968 instance,
969 volume,
970 proxy,
971 projectId: resolvedProjectId,
972 environmentId: resolvedEnvId,
973 user: vars.POSTGRES_USER ?? output?.user ?? DEFAULT_POSTGRES_USER,
974 password: vars.POSTGRES_PASSWORD ?? "",
975 database:
976 vars.POSTGRES_DB ?? output?.database ?? DEFAULT_POSTGRES_DATABASE,
977 });
978 if (output !== undefined) return attrs;
979 return matchesAlchemyPhysicalName(found.name) ? attrs : Unowned(attrs);
980 }),
981
982 list: Effect.fn(function* () {
983 const projects = yield* ownedProjects();
984 const rows = yield* Effect.forEach(projects, (project) =>
985 Effect.gen(function* () {
986 const services = new Map(
987 (yield* listProjectServices(project.projectId))
988 .filter((service) => matchesAlchemyPhysicalName(service.name))
989 .map((service) => [service.id, service]),
990 );
991 if (services.size === 0) return [];
992 const envIds = yield* liveEnvironmentIds(project.projectId).pipe(
993 Stream.filter((env) => env.deletedAt == null),
994 Stream.map((env) => env.id),
995 Stream.runCollect,
996 Effect.catchTag("RailwayNotFound", () => Effect.succeed([])),
997 );
998 const items = yield* Effect.forEach(envIds, (environmentId) =>
999 Effect.gen(function* () {
1000 const instances = (yield* environmentServiceInstances(
1001 environmentId,
1002 project.projectId,
1003 (instance) => ({
1004 ...attributeInstanceFields(instance),
1005 serviceId: instance.serviceId,
1006 deletedAt: instance.deletedAt,
1007 }),
1008 )).filter(
1009 (instance) =>
1010 instance.deletedAt == null &&
1011 services.has(instance.serviceId) &&
1012 isPostgresImage(instance.source?.image),
1013 );
1014 if (instances.length === 0) return [];
1015 const volumes = yield* listVolumeInstances(
1016 environmentId,
1017 project.projectId,
1018 );
1019 return instances.flatMap((instance) => {
1020 const service = services.get(instance.serviceId);
1021 return service === undefined
1022 ? []
1023 : [
1024 toAttrs({
1025 service,
1026 instance,
1027 projectId: project.projectId,
1028 environmentId,
1029 volume: volumes.find(
1030 (row) => row.serviceId === service.id,
1031 ),
1032 proxy: undefined,
1033 user: DEFAULT_POSTGRES_USER,
1034 password: "",
1035 database: DEFAULT_POSTGRES_DATABASE,
1036 }),
1037 ];
1038 });
1039 }),
1040 );
1041 return items.flat();
1042 }),
1043 );
1044 return rows.flat();
1045 }),
1046
1047 reconcile: Effect.fn(function* ({ id, news, output }) {
1048 const props = news ?? ({} as PostgresProps);
1049 const projectId = projectIdOf(props.project) ?? output?.projectId;
1050 if (projectId === undefined) {
1051 return yield* new PostgresProjectRequired({
1052 message: "Postgres requires a resolved Railway.Project",
1053 });
1054 }
1055 const environmentId =
1056 environmentIdOf(props.environment) ??
1057 environmentIdOf(props.project) ??
1058 output?.environmentId;
1059 if (environmentId === undefined) {
1060 return yield* new PostgresProjectRequired({
1061 message:
1062 "Postgres requires a Railway environment (pass environment or a Project with environmentId)",
1063 });
1064 }
1065 const name = yield* resolveName(id, props.name, output?.name);
1066 const sourceImage = props.image ?? DEFAULT_POSTGRES_IMAGE;
1067 const wantPublic = props.public !== false;
1068 const volumeName = yield* createRailwayName(`${id}-pgdata`);
1069
1070 let current: CloudService | undefined =
1071 output?.serviceId !== undefined && output.serviceId.length > 0
1072 ? yield* getById(output.serviceId)
1073 : undefined;
1074 if (current === undefined) {
1075 current = yield* findByName(projectId, name);
1076 }
1077
1078 const existingVars =
1079 current !== undefined
1080 ? yield* listVariableMap(projectId, environmentId, current.id)
1081 : {};
1082 const user =
1083 existingVars.POSTGRES_USER ?? props.user ?? DEFAULT_POSTGRES_USER;
1084 const database =
1085 existingVars.POSTGRES_DB ?? props.database ?? DEFAULT_POSTGRES_DATABASE;
1086 const password =
1087 existingVars.POSTGRES_PASSWORD ??
1088 (props.password !== undefined
1089 ? unwrapSecret(props.password)
1090 : undefined) ??
1091 (yield* generatePassword);
1092 const variables = desiredVariables({ user, password, database });
1093
1094 if (current === undefined) {
1095 const created = yield* serviceCreate({
1096 projectId,
1097 environmentId,
1098 name,
1099 source: { image: sourceImage },
1100 variables,
1101 }).pipe(
1102 Effect.catchTag("RailwayValidationError", () =>
1103 Effect.succeed(undefined),
1104 ),
1105 );
1106 current = created ?? (yield* findByName(projectId, name));
1107 }
1108
1109 if (current === undefined || isGoneService(current)) {
1110 return yield* new PostgresNotCreated({ name, projectId });
1111 }
1112
1113 if (current.name !== name) {
1114 current = yield* serviceUpdateName(current.id, name);
1115 }
1116
1117 let instance = yield* waitForInstance(environmentId, current.id);
1118 let needsDeploy = false;
1119
1120 const observedImage = instance?.source?.image ?? undefined;
1121 const imageChanged =
1122 sourceImage !== undefined && !sameImage(observedImage, sourceImage);
1123 const observedRegion = instance?.region ?? undefined;
1124 const regionChanged =
1125 props.region !== undefined && props.region !== observedRegion;
1126 const sleepOn = instance?.sleepApplication !== false;
1127 if (imageChanged || regionChanged || sleepOn) {
1128 yield* serviceInstanceUpdate(environmentId, current.id, {
1129 ...(imageChanged ? { source: { image: sourceImage } } : {}),
1130 ...(regionChanged ? { region: props.region } : {}),
1131 ...(sleepOn ? { sleepApplication: false } : {}),
1132 });
1133 needsDeploy = true;
1134 instance = (yield* getInstance(environmentId, current.id)) ?? instance;
1135 }
1136
1137 const envChanged = yield* syncEnv({
1138 projectId,
1139 environmentId,
1140 serviceId: current.id,
1141 desired: variables,
1142 });
1143 if (envChanged) needsDeploy = true;
1144
1145 let volume: CloudInstance | undefined =
1146 output?.volumeInstanceId !== undefined &&
1147 output.volumeInstanceId.length > 0
1148 ? yield* getVolumeByInstanceId(output.volumeInstanceId)
1149 : undefined;
1150 if (volume === undefined && output?.volumeId !== undefined) {
1151 volume = yield* findVolume(
1152 environmentId,
1153 projectId,
1154 (row) => row.volumeId === output.volumeId,
1155 );
1156 }
1157 if (volume === undefined) {
1158 volume = yield* findVolume(
1159 environmentId,
1160 projectId,
1161 (row) =>
1162 (row.serviceId ?? undefined) === current!.id ||
1163 row.volume.name === volumeName,
1164 );
1165 }
1166 if (volume === undefined) {
1167 const created = yield* volumeCreate({
1168 projectId,
1169 environmentId,
1170 mountPath: POSTGRES_MOUNT_PATH,
1171 serviceId: current.id,
1172 ...(props.region !== undefined ? { region: props.region } : {}),
1173 });
1174 if (created.name !== volumeName) {
1175 yield* stampVolumeName(created.id, volumeName);
1176 }
1177 volume = yield* waitForVolume(environmentId, projectId, created.id);
1178 }
1179 if (volume === undefined || isGoneVolume(volume)) {
1180 return yield* new PostgresVolumeNotCreated({
1181 name: volumeName,
1182 serviceId: current.id,
1183 });
1184 }
1185 if (volume.volume.name !== volumeName) {
1186 yield* stampVolumeName(volume.volumeId, volumeName);
1187 }
1188 const observedMount = volume.mountPath;
1189 const observedServiceId = volume.serviceId ?? undefined;
1190 const mountChanged = observedMount !== POSTGRES_MOUNT_PATH;
1191 const attached = observedServiceId === current.id;
1192 if (mountChanged || !attached) {
1193 yield* volumeInstanceUpdate(volume.volumeId, environmentId, {
1194 ...(mountChanged ? { mountPath: POSTGRES_MOUNT_PATH } : {}),
1195 ...(!attached ? { serviceId: current.id } : {}),
1196 });
1197 volume =
1198 (yield* waitForVolume(environmentId, projectId, volume.volumeId)) ??
1199 volume;
1200 needsDeploy = true;
1201 }
1202
1203 let proxy = yield* findProxy(environmentId, current.id, POSTGRES_PORT);
1204 if (wantPublic && proxy === undefined) {
1205 const created = yield* tcpProxyCreate({
1206 applicationPort: POSTGRES_PORT,
1207 environmentId,
1208 serviceId: current.id,
1209 }).pipe(
1210 Effect.catchTag("RailwayValidationError", () =>
1211 Effect.succeed(undefined),
1212 ),
1213 );
1214 proxy =
1215 created !== undefined && !isGoneProxy(created)
1216 ? created
1217 : yield* findProxy(environmentId, current.id, POSTGRES_PORT);
1218 }
1219 if (!wantPublic && proxy !== undefined) {
1220 yield* deleteProxy(proxy.id);
1221 proxy = undefined;
1222 }
1223
1224 if (needsDeploy || instance?.latestDeployment == null) {
1225 yield* serviceInstanceDeploy(environmentId, current.id).pipe(
1226 Effect.catchTag("RailwayValidationError", () => Effect.void),
1227 );
1228 }
1229
1230 instance =
1231 (yield* waitForDeployment(environmentId, current.id)) ?? instance;
1232 let finalStatus = instance?.latestDeployment?.status;
1233 // A deployment can wedge in DEPLOYING and never reach SUCCESS — the
1234 // container may serve, but Railway keeps its per-environment operation
1235 // lock and the TCP proxy's routing is not committed. Converge: cancel
1236 // the wedged deployment, redeploy once, and insist on SUCCESS.
1237 if (!deployFailed(finalStatus) && !deployReady(finalStatus)) {
1238 const wedged = instance?.latestDeployment?.id;
1239 if (wedged != null && wedged.length > 0) {
1240 yield* deploymentCancel(wedged).pipe(
1241 Effect.catchTag("RailwayNotFound", () => Effect.void),
1242 );
1243 }
1244 yield* serviceInstanceDeploy(environmentId, current.id).pipe(
1245 Effect.catchTag("RailwayValidationError", () => Effect.void),
1246 );
1247 instance =
1248 (yield* waitForDeployment(environmentId, current.id)) ?? instance;
1249 finalStatus = instance?.latestDeployment?.status;
1250 }
1251 if (deployFailed(finalStatus) || !deployReady(finalStatus)) {
1252 return yield* new PostgresDeployFailed({
1253 serviceId: current.id,
1254 status: finalStatus ?? "failed",
1255 deploymentId: instance?.latestDeployment?.id,
1256 });
1257 }
1258
1259 return toAttrs({
1260 service: current,
1261 instance,
1262 volume,
1263 proxy,
1264 projectId,
1265 environmentId,
1266 user,
1267 password,
1268 database,
1269 });
1270 }),
1271
1272 delete: Effect.fn(function* ({ output }) {
1273 const serviceId = output.serviceId;
1274 const environmentId = output.environmentId;
1275 // Cancel a still-running deployment first: it holds Railway's
1276 // per-environment operation lock ("Cannot delete TCP proxy: an
1277 // operation is already in progress") and can stall the service
1278 // teardown indefinitely. A finished deployment makes this a no-op.
1279 if (environmentId.length > 0 && serviceId.length > 0) {
1280 const instance = yield* getInstance(environmentId, serviceId);
1281 const latest = instance?.latestDeployment;
1282 if (
1283 latest?.id != null &&
1284 latest.id.length > 0 &&
1285 !deployReady(latest.status) &&
1286 !deployFailed(latest.status)
1287 ) {
1288 yield* deploymentCancel(latest.id).pipe(
1289 Effect.catchTag("RailwayNotFound", () => Effect.void),
1290 );
1291 }
1292 }
1293 // Delete the SERVICE next — its teardown cascades onto the proxies.
1294 if (serviceId.length > 0) {
1295 yield* serviceDelete(serviceId).pipe(
1296 Effect.catchTag("RailwayNotFound", () => Effect.void),
1297 );
1298 yield* waitUntilDeleted(
1299 "Service",
1300 serviceId,
1301 getById(serviceId).pipe(
1302 Effect.map((service) => service === undefined),
1303 ),
1304 );
1305 }
1306 // Proxies usually disappear with the service; wait for the cascade
1307 // instead of fighting the teardown's lock, then force-delete any
1308 // survivor (which no-ops on NotFound).
1309 if (environmentId.length > 0 && serviceId.length > 0) {
1310 const leftover = yield* listProxies(environmentId, serviceId).pipe(
1311 Effect.repeat({
1312 schedule: Schedule.spaced("3 seconds"),
1313 until: (rows) => rows.length === 0,
1314 times: 10,
1315 }),
1316 );
1317 yield* Effect.forEach(leftover, (proxy) => deleteProxy(proxy.id), {
1318 concurrency: 4,
1319 });
1320 yield* waitUntilDeleted(
1321 "TcpProxy",
1322 serviceId,
1323 listProxies(environmentId, serviceId).pipe(
1324 Effect.map((proxies) => proxies.length === 0),
1325 ),
1326 );
1327 } else if (
1328 output.tcpProxyId !== undefined &&
1329 output.tcpProxyId.length > 0
1330 ) {
1331 yield* deleteProxy(output.tcpProxyId);
1332 }
1333 if (output.volumeId.length > 0) {
1334 yield* volumeDelete(output.volumeId).pipe(
1335 Effect.catchTag("RailwayNotFound", () => Effect.void),
1336 );
1337 const check =
1338 output.volumeInstanceId.length > 0
1339 ? getVolumeByInstanceId(output.volumeInstanceId).pipe(
1340 Effect.map((instance) => instance === undefined),
1341 )
1342 : Effect.succeed(true);
1343 yield* waitUntilDeleted("Volume", output.volumeId, check);
1344 }
1345 }),
1346 });