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