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_MYSQL_IMAGE = "mysql:9";
175export const DEFAULT_MYSQL_USER = "root";
176export const DEFAULT_MYSQL_DATABASE = "railway";
177export const MYSQL_PORT = 3306;
178export const MYSQL_MOUNT_PATH = "/var/lib/mysql";
179export const MYSQL_URL_SECRET = "MYSQL_URL";
180export const MYSQL_PUBLIC_URL_SECRET = "MYSQL_PUBLIC_URL";
181/** Railway's official MySQL template start command (native AIO fails on volumes). */
182export const DEFAULT_MYSQL_START_COMMAND =
183 "docker-entrypoint.sh mysqld --innodb-use-native-aio=0 --disable-log-bin --performance_schema=0";
184
185/**
186 * Environment identity MySQL is deployed into. Accepts a
187 * `Railway.Project` (its primary environment), a `Railway.Environment`,
188 * or an `{ environmentId }` stub.
189 */
190export type MySQLEnvironment = {
191 readonly environmentId: string;
192};
193
194export interface MySQLProps {
195 /**
196 * Parent Railway Project. Accepts a `Railway.Project` or an Effect
197 * that produces one. Changing the Project replaces MySQL.
198 */
199 project: Ref<Project>;
200 /**
201 * Environment to deploy into. Accepts a `Railway.Project` (primary
202 * environment), a `Railway.Environment`, or `{ environmentId }`.
203 * Defaults to the project's primary environment. Changing it replaces
204 * MySQL.
205 */
206 environment?: Ref<MySQLEnvironment>;
207 /**
208 * Service name. Unique per Project. If omitted, a unique name is
209 * generated from the stack, stage and logical ID. Used as the private
210 * hostname `{name}.railway.internal`. Changing it updates in place.
211 */
212 name?: string;
213 /**
214 * MySQL image. Default is the official Docker Hub `mysql:9` tag.
215 *
216 * @default "mysql:9"
217 */
218 image?: string;
219 /**
220 * Region for the service instance and volume (`us-west2`, `us-east4`,
221 * …). If omitted, Railway picks the default. Changing it replaces
222 * MySQL.
223 */
224 region?: string;
225 /**
226 * Superuser name. Create-only (used at first `mysqld` init). Default
227 * `root` matches Railway's `mysql()` helper.
228 *
229 * @default "root"
230 */
231 user?: string;
232 /**
233 * Superuser password. Wrap with `Redacted.make(...)`. If omitted, a
234 * password is generated on first create and stored as
235 * `MYSQL_ROOT_PASSWORD`. Create-only.
236 */
237 password?: Redacted.Redacted<string> | string;
238 /**
239 * Initial database name. Create-only.
240 *
241 * @default "railway"
242 */
243 database?: string;
244 /**
245 * Expose MySQL on a public TCP proxy (`*.proxy.rlwy.net`) for
246 * laptop access and deploy-time migrations. In-service connections
247 * always use `{name}.railway.internal`.
248 *
249 * @default true
250 */
251 public?: boolean;
252}
253
254export type MySQL = Resource<
255 "Railway.MySQL",
256 MySQLProps,
257 {
258 /** Railway service id for the MySQL container. */
259 serviceId: string;
260 /** Physical service name. Private hostname is `{name}.railway.internal`. */
261 name: string;
262 /** Parent Railway project id. */
263 projectId: string;
264 /** Environment the instance is deployed in. */
265 environmentId: string;
266 /** Observed `source.image`. */
267 image: string | undefined;
268 /** Observed region, if Railway reported one. */
269 region: string | undefined;
270 /** Volume id holding `/var/lib/mysql`. */
271 volumeId: string;
272 /** Volume instance id in the target environment. */
273 volumeInstanceId: string;
274 /** TCP proxy id, when `public` is enabled. */
275 tcpProxyId: string | undefined;
276 /** Public proxy hostname (`*.proxy.rlwy.net`). */
277 tcpProxyDomain: string | undefined;
278 /** Public proxy port. Pair with `tcpProxyDomain`. */
279 tcpProxyPort: number | undefined;
280 /** Superuser name. */
281 user: string;
282 /** Database name. */
283 database: string;
284 /**
285 * Private MySQL URI (`{name}.railway.internal:3306`). Prefer
286 * {@link ConnectMySQL} from a {@link Service}.
287 */
288 connectionUri: string;
289 /**
290 * Public TCP-proxy URI. Empty when `public` is false. Use this from
291 * the laptop / deploy-time migrations.
292 */
293 publicConnectionUri: string;
294 /** Latest deployment id, if one exists. */
295 deploymentId: string | undefined;
296 /** Latest deployment status (`SUCCESS`, `DEPLOYING`, …). */
297 deploymentStatus: string | undefined;
298 },
299 never,
300 Providers
301>;
302
303const resolveMySQLProps = (
304 props: MySQLProps | Effect.Effect<MySQLProps, never, Providers>,
305): Effect.Effect<MySQLProps, never, Providers> =>
306 Effect.gen(function* () {
307 const resolved = Effect.isEffect(props) ? yield* props : props;
308 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
309 const project = Effect.isEffect(resolved.project)
310 ? yield* resolved.project as Effect.Effect<Project, never, Providers>
311 : resolved.project;
312 const environment =
313 resolved.environment === undefined
314 ? undefined
315 : Effect.isEffect(resolved.environment)
316 ? yield* resolved.environment as Effect.Effect<
317 MySQLEnvironment,
318 never,
319 Providers
320 >
321 : resolved.environment;
322 return { ...resolved, project, environment };
323 });
324
325const MySQLResource = Resource<MySQL>("Railway.MySQL");
326
327/**
328 * A Railway.MySQL is a MySQL-as-a-Service: the official `mysql` image,
329 * a Volume at `/var/lib/mysql`, `MYSQL_*` / `MYSQL_URL` variables, and
330 * an optional TCP proxy for the public URL. This is Alchemy's
331 * `mysql()` helper (Railway IaC: `mysql("mysql")`).
332 *
333 * Private hostname is `{name}.railway.internal`. From a
334 * {@link Service}, yield {@link ConnectMySQL}. From a laptop, use
335 * `publicConnectionUri`.
336 *
337 * @see https://docs.railway.com/databases/mysql
338 *
339 * ### Create MySQL
340 * Pass a Project. Alchemy generates a unique name, password, volume,
341 * and a public TCP proxy.
342 *
343 * **Example:** Generated name
344 * ```typescript
345 * const site = yield* Railway.Project("Site");
346 * const db = yield* Railway.MySQL("Db", { project: site });
347 * ```
348 *
349 * :::caution[Changing `project` replaces MySQL]
350 * A new service + volume are created in the new Project. The old
351 * service and volume are deleted. Data is not copied.
352 * :::
353 *
354 * ### Connect from a Service
355 * Yield `ConnectMySQL` inside init. Provide
356 * {@link ConnectMySQLHttp}. Pass `conn.connectionString` to
357 * `Drizzle.MySQL` or `SQL.MySQL`. The binding packs the private
358 * URI (`{name}.railway.internal`).
359 *
360 * **Example:** Bind and query
361 * ```typescript
362 * import * as Drizzle from "alchemy/Drizzle/MySQL";
363 * import * as HttpServerResponse from "effect/http/HttpServerResponse";
364 *
365 * export default class Api extends Railway.Service<Api>()(
366 * "Api",
367 * { project: Site, main: import.meta.url, build: { install: ["mysql2"] } },
368 * Effect.gen(function* () {
369 * const conn = yield* Railway.ConnectMySQL(Db);
370 * const db = yield* Drizzle.MySQL(conn.connectionString);
371 * return {
372 * fetch: Effect.gen(function* () {
373 * const rows = yield* db.execute("select 1 as ok", "objects");
374 * return HttpServerResponse.json({ rows });
375 * }),
376 * };
377 * }).pipe(Effect.provide(Railway.ConnectMySQLHttp)),
378 * ) {}
379 * ```
380 *
381 * ### Public TCP
382 * `public` (default `true`) creates a TCP proxy on 3306.
383 * `publicConnectionUri` is `{domain}:{proxyPort}` for laptop access
384 * and deploy-time migrations.
385 *
386 * **Example:** Private only
387 * ```typescript
388 * const db = yield* Railway.MySQL("Db", {
389 * project: site,
390 * public: false,
391 * });
392 * ```
393 *
394 * ### Image
395 * Default is official MySQL 9. Pass `image` to pin another tag.
396 *
397 * **Example:** MySQL 8
398 * ```typescript
399 * const db = yield* Railway.MySQL("Db", {
400 * project: site,
401 * image: "mysql:8",
402 * });
403 * ```
404 *
405 * ### Module-scope declarations
406 * Resource-valued props accept the resource or an Effect producing it.
407 *
408 * **Example:** Module-scope MySQL
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.MySQL("Db", { project: Site });
415 * ```
416 *
417 * @resource
418 * @product MySQL
419 */
420export const MySQL: typeof MySQLResource = Object.assign(
421 (
422 id: string,
423 props: MySQLProps | Effect.Effect<MySQLProps, never, Providers>,
424 ) => MySQLResource(id, resolveMySQLProps(props)),
425 MySQLResource,
426);
427
428/**
429 * IaC helper matching Railway's `mysql()`. Same resource as {@link MySQL}.
430 */
431export const mysql = MySQL;
432
433export class MySQLNotCreated extends Data.TaggedError(
434 "Railway.MySQLNotCreated",
435)<{
436 name: string;
437 projectId: string;
438}> {}
439
440export class MySQLProjectRequired extends Data.TaggedError(
441 "Railway.MySQLProjectRequired",
442)<{
443 message: string;
444}> {}
445
446export class MySQLDeployFailed extends Data.TaggedError(
447 "Railway.MySQLDeployFailed",
448)<{
449 serviceId: string;
450 status: string;
451 deploymentId: string | undefined;
452}> {}
453
454export class MySQLVolumeNotCreated extends Data.TaggedError(
455 "Railway.MySQLVolumeNotCreated",
456)<{
457 name: string;
458 serviceId: string;
459}> {}
460
461class MySQLPending extends Data.TaggedError("Railway.MySQLPending")<{
462 serviceId: string;
463 status: string;
464}> {}
465
466class MySQLDeployPending extends Data.TaggedError(
467 "Railway.MySQLDeployPending",
468)<{
469 serviceId: string;
470 status: string;
471}> {}
472
473class VolumePending extends Data.TaggedError("Railway.MySQLVolumePending")<{
474 volumeId: string;
475 state: string;
476}> {}
477
478const projectIdOf = (value: unknown): string | undefined => {
479 if (value === null || typeof value !== "object") return undefined;
480 const rec = value as { projectId?: unknown };
481 return typeof rec.projectId === "string" && rec.projectId.length > 0
482 ? rec.projectId
483 : undefined;
484};
485
486const environmentIdOf = (value: unknown): string | undefined => {
487 if (value === null || typeof value !== "object") return undefined;
488 const rec = value as { environmentId?: unknown };
489 return typeof rec.environmentId === "string" && rec.environmentId.length > 0
490 ? rec.environmentId
491 : undefined;
492};
493
494const unwrapSecret = (value: Redacted.Redacted<string> | string): string =>
495 Redacted.isRedacted(value) ? Redacted.value(value) : value;
496
497const generatePassword = Effect.sync(() => {
498 const bytes = randomBytes(16);
499 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
500});
501
502const isGoneService = (service: CloudService | undefined) =>
503 service === undefined || service.deletedAt != null;
504
505const isGoneInstance = (instance: ServiceInstanceResponse | undefined) =>
506 instance === undefined || instance.deletedAt != null;
507
508const goneVolumeState = (state: VolumeState | null | undefined) =>
509 state === "DELETED" || state === "DELETING";
510
511const transientVolumeState = (state: VolumeState | null | undefined) =>
512 state === "UPDATING" ||
513 state === "MIGRATING" ||
514 state === "MIGRATION_PENDING" ||
515 state === "RESTORING";
516
517const isGoneVolume = (instance: CloudInstance | undefined) =>
518 instance === undefined ||
519 instance.deletedAt != null ||
520 instance.isPendingDeletion ||
521 goneVolumeState(instance.state);
522
523const isGoneProxy = (proxy: CloudProxy | undefined) =>
524 proxy === undefined ||
525 proxy.deletedAt != null ||
526 proxy.syncStatus === "DELETED";
527
528const normalizeDomain = (domain: string) => domain.replace(/\.+$/, "");
529
530const sameImage = (observed: string | null | undefined, desired: string) => {
531 if (observed == null || observed.length === 0) return false;
532 if (observed === desired) return true;
533 if (observed === `${desired}:latest` || desired === `${observed}:latest`) {
534 return true;
535 }
536 return (
537 observed.endsWith(`/${desired}`) || observed.endsWith(`/${desired}:latest`)
538 );
539};
540
541const isMysqlImage = (image: string | null | undefined) =>
542 image != null && /(^|\/)mysql([:-]|$)/i.test(image);
543
544const mysqlStartCommand = (image: string) =>
545 isMysqlImage(image) ? DEFAULT_MYSQL_START_COMMAND : undefined;
546
547const deployReady = (status: string | undefined) => status === "SUCCESS";
548
549const deployFailed = (status: string | undefined) =>
550 status === "FAILED" || status === "CRASHED" || status === "REMOVED";
551
552const resolveName = (id: string, name: string | undefined, existing?: string) =>
553 Effect.gen(function* () {
554 if (name !== undefined) return name;
555 if (existing !== undefined) return existing;
556 return yield* createRailwayName(id);
557 });
558
559const encodePart = (value: string) => encodeURIComponent(value);
560
561const isTemplateValue = (value: string | undefined) =>
562 value !== undefined && value.includes("${{");
563
564const privateConnectionUri = (input: {
565 user: string;
566 password: string;
567 name: string;
568 database: string;
569}): string =>
570 `mysql://${encodePart(input.user)}:${encodePart(input.password)}@${input.name}.railway.internal:${MYSQL_PORT}/${encodePart(input.database)}`;
571
572const publicConnectionUri = (input: {
573 user: string;
574 password: string;
575 domain: string;
576 port: number;
577 database: string;
578}): string =>
579 `mysql://${encodePart(input.user)}:${encodePart(input.password)}@${input.domain}:${input.port}/${encodePart(input.database)}`;
580
581const desiredVariables = (input: {
582 user: string;
583 password: string;
584 database: string;
585}): Record<string, string> => {
586 const vars: Record<string, string> = {
587 MYSQL_ROOT_PASSWORD: input.password,
588 MYSQL_DATABASE: input.database,
589 MYSQLHOST: "${{RAILWAY_PRIVATE_DOMAIN}}",
590 MYSQLPORT: String(MYSQL_PORT),
591 MYSQLUSER: input.user,
592 MYSQLPASSWORD: input.password,
593 MYSQLDATABASE: input.database,
594 [MYSQL_URL_SECRET]:
595 "mysql://${{MYSQLUSER}}:${{MYSQLPASSWORD}}@${{RAILWAY_PRIVATE_DOMAIN}}:3306/${{MYSQLDATABASE}}",
596 [MYSQL_PUBLIC_URL_SECRET]:
597 "mysql://${{MYSQLUSER}}:${{MYSQLPASSWORD}}@${{RAILWAY_TCP_PROXY_DOMAIN}}:${{RAILWAY_TCP_PROXY_PORT}}/${{MYSQLDATABASE}}",
598 };
599 if (input.user !== "root") {
600 vars.MYSQL_USER = input.user;
601 vars.MYSQL_PASSWORD = input.password;
602 }
603 return vars;
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}): MySQL["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 MySQLPending({ serviceId, status: "creating" }));
810 }
811 return Effect.succeed(instance);
812 }),
813 Effect.retry({
814 while: (e) => e._tag === "Railway.MySQLPending",
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.MySQLPending", () =>
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 MySQLDeployPending({
835 serviceId,
836 status: status ?? "pending",
837 }),
838 );
839 }),
840 Effect.retry({
841 while: (e) => e._tag === "Railway.MySQLDeployPending",
842 times: 10,
843 schedule: Schedule.spaced("5 seconds"),
844 }),
845 Effect.catchTag("Railway.MySQLDeployPending", () =>
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.MySQLVolumePending",
878 times: 10,
879 schedule: Schedule.spaced("3 seconds"),
880 }),
881 Effect.catchTag("Railway.MySQLVolumePending", () => observe),
882 );
883};
884
885const stampVolumeName = (volumeId: string, name: string) =>
886 volumeUpdate(volumeId, name);
887
888const passwordFromVars = (
889 vars: Record<string, string>,
890 fallback?: string,
891): string | undefined => {
892 const root = vars.MYSQL_ROOT_PASSWORD;
893 if (root !== undefined && root.length > 0 && !isTemplateValue(root)) {
894 return root;
895 }
896 const userPass = vars.MYSQLPASSWORD;
897 if (
898 userPass !== undefined &&
899 userPass.length > 0 &&
900 !isTemplateValue(userPass)
901 ) {
902 return userPass;
903 }
904 return fallback !== undefined && fallback.length > 0 ? fallback : undefined;
905};
906
907export const MySQLProvider = () =>
908 Provider.succeed(MySQL, {
909 stables: ["serviceId", "projectId", "environmentId", "volumeId"],
910 nuke: { dependsOn: ["Railway.Project"] },
911
912 diff: Effect.fn(function* ({ news, output }) {
913 if (news === undefined || !isResolved(news)) return undefined;
914 if (output === undefined) return undefined;
915 const nextProject = projectIdOf(news.project);
916 const projectChanged =
917 nextProject !== undefined && nextProject !== output.projectId;
918 const nextEnv = environmentIdOf(news.environment);
919 const environmentChanged =
920 nextEnv !== undefined && nextEnv !== output.environmentId;
921 const regionChanged =
922 news.region !== undefined && news.region !== output.region;
923 if (projectChanged || environmentChanged || regionChanged) {
924 return { action: "replace" as const };
925 }
926 return undefined;
927 }),
928
929 read: Effect.fn(function* ({ id, olds, output }) {
930 const projectId =
931 output?.projectId ??
932 (olds !== undefined ? projectIdOf(olds.project) : undefined);
933 const environmentId =
934 output?.environmentId ??
935 (olds !== undefined
936 ? (environmentIdOf(olds.environment) ?? environmentIdOf(olds.project))
937 : undefined);
938 const name = yield* resolveName(id, olds?.name, output?.name);
939 const byId =
940 output?.serviceId !== undefined && output.serviceId.length > 0
941 ? yield* getById(output.serviceId)
942 : undefined;
943 const found =
944 byId ??
945 (projectId !== undefined
946 ? yield* findByName(projectId, name)
947 : undefined);
948 if (found === undefined) return undefined;
949 const resolvedProjectId = projectIdOf(found) ?? projectId ?? "";
950 const resolvedEnvId =
951 environmentId ??
952 environmentIdOf(olds?.project) ??
953 output?.environmentId ??
954 "";
955 const instance =
956 resolvedEnvId.length > 0
957 ? yield* getInstance(resolvedEnvId, found.id)
958 : undefined;
959 const volume =
960 output?.volumeInstanceId !== undefined &&
961 output.volumeInstanceId.length > 0
962 ? yield* getVolumeByInstanceId(output.volumeInstanceId)
963 : resolvedEnvId.length > 0 && resolvedProjectId.length > 0
964 ? yield* findVolume(
965 resolvedEnvId,
966 resolvedProjectId,
967 (row) =>
968 (row.serviceId ?? undefined) === found.id ||
969 (output?.volumeId !== undefined &&
970 row.volumeId === output.volumeId),
971 )
972 : undefined;
973 const proxy =
974 resolvedEnvId.length > 0
975 ? yield* findProxy(resolvedEnvId, found.id, MYSQL_PORT)
976 : undefined;
977 const vars =
978 resolvedProjectId.length > 0 && resolvedEnvId.length > 0
979 ? yield* listVariableMap(resolvedProjectId, resolvedEnvId, found.id)
980 : {};
981 const attrs = toAttrs({
982 service: found,
983 instance,
984 volume,
985 proxy,
986 projectId: resolvedProjectId,
987 environmentId: resolvedEnvId,
988 user:
989 vars.MYSQLUSER ??
990 vars.MYSQL_USER ??
991 output?.user ??
992 DEFAULT_MYSQL_USER,
993 password: passwordFromVars(vars) ?? "",
994 database:
995 vars.MYSQLDATABASE ??
996 vars.MYSQL_DATABASE ??
997 output?.database ??
998 DEFAULT_MYSQL_DATABASE,
999 });
1000 if (output !== undefined) return attrs;
1001 return matchesAlchemyPhysicalName(found.name) ? attrs : Unowned(attrs);
1002 }),
1003
1004 list: Effect.fn(function* () {
1005 const projects = yield* ownedProjects();
1006 const rows = yield* Effect.forEach(projects, (project) =>
1007 Effect.gen(function* () {
1008 const services = new Map(
1009 (yield* listProjectServices(project.projectId))
1010 .filter((service) => matchesAlchemyPhysicalName(service.name))
1011 .map((service) => [service.id, service]),
1012 );
1013 if (services.size === 0) return [];
1014 const envIds = yield* liveEnvironments(project.projectId).pipe(
1015 Stream.filter((env) => env.deletedAt == null),
1016 Stream.map((env) => env.id),
1017 Stream.runCollect,
1018 Effect.catchTag("RailwayNotFound", () => Effect.succeed([])),
1019 );
1020 const items = yield* Effect.forEach(envIds, (environmentId) =>
1021 Effect.gen(function* () {
1022 const instances = (yield* environmentServiceInstances(
1023 environmentId,
1024 project.projectId,
1025 (instance) => ({
1026 ...attributeInstanceFields(instance),
1027 serviceId: instance.serviceId,
1028 deletedAt: instance.deletedAt,
1029 }),
1030 )).filter(
1031 (instance) =>
1032 instance.deletedAt == null &&
1033 services.has(instance.serviceId) &&
1034 isMysqlImage(instance.source?.image),
1035 );
1036 if (instances.length === 0) return [];
1037 const volumes = yield* listVolumeInstances(
1038 environmentId,
1039 project.projectId,
1040 );
1041 return instances.flatMap((instance) => {
1042 const service = services.get(instance.serviceId);
1043 return service === undefined
1044 ? []
1045 : [
1046 toAttrs({
1047 service,
1048 instance,
1049 projectId: project.projectId,
1050 environmentId,
1051 volume: volumes.find(
1052 (row) => row.serviceId === service.id,
1053 ),
1054 proxy: undefined,
1055 user: DEFAULT_MYSQL_USER,
1056 password: "",
1057 database: DEFAULT_MYSQL_DATABASE,
1058 }),
1059 ];
1060 });
1061 }),
1062 );
1063 return items.flat();
1064 }),
1065 );
1066 return rows.flat();
1067 }),
1068
1069 reconcile: Effect.fn(function* ({ id, news, output }) {
1070 const props = news ?? ({} as MySQLProps);
1071 const projectId = projectIdOf(props.project) ?? output?.projectId;
1072 if (projectId === undefined) {
1073 return yield* new MySQLProjectRequired({
1074 message: "MySQL requires a resolved Railway.Project",
1075 });
1076 }
1077 const environmentId =
1078 environmentIdOf(props.environment) ??
1079 environmentIdOf(props.project) ??
1080 output?.environmentId;
1081 if (environmentId === undefined) {
1082 return yield* new MySQLProjectRequired({
1083 message:
1084 "MySQL requires a Railway environment (pass environment or a Project with environmentId)",
1085 });
1086 }
1087 const name = yield* resolveName(id, props.name, output?.name);
1088 const sourceImage = props.image ?? DEFAULT_MYSQL_IMAGE;
1089 const wantPublic = props.public !== false;
1090 const volumeName = yield* createRailwayName(`${id}-mysqldata`);
1091 const startCommand = mysqlStartCommand(sourceImage);
1092
1093 let current: CloudService | undefined =
1094 output?.serviceId !== undefined && output.serviceId.length > 0
1095 ? yield* getById(output.serviceId)
1096 : undefined;
1097 if (current === undefined) {
1098 current = yield* findByName(projectId, name);
1099 }
1100
1101 const existingVars =
1102 current !== undefined
1103 ? yield* listVariableMap(projectId, environmentId, current.id)
1104 : {};
1105 const user =
1106 existingVars.MYSQLUSER ??
1107 existingVars.MYSQL_USER ??
1108 props.user ??
1109 DEFAULT_MYSQL_USER;
1110 const database =
1111 existingVars.MYSQLDATABASE ??
1112 existingVars.MYSQL_DATABASE ??
1113 props.database ??
1114 DEFAULT_MYSQL_DATABASE;
1115 const password =
1116 passwordFromVars(existingVars) ??
1117 (props.password !== undefined
1118 ? unwrapSecret(props.password)
1119 : undefined) ??
1120 (yield* generatePassword);
1121 const variables = desiredVariables({ user, password, database });
1122
1123 if (current === undefined) {
1124 const created = yield* serviceCreate({
1125 input: {
1126 projectId,
1127 environmentId,
1128 name,
1129 source: { image: sourceImage },
1130 variables,
1131 },
1132 }).pipe(
1133 Effect.catchTag("RailwayValidationError", () =>
1134 Effect.succeed(undefined),
1135 ),
1136 );
1137 current = created ?? (yield* findByName(projectId, name));
1138 }
1139
1140 if (current === undefined || isGoneService(current)) {
1141 return yield* new MySQLNotCreated({ name, projectId });
1142 }
1143
1144 if (current.name !== name) {
1145 current = yield* serviceUpdate({ id: current.id, input: { name } });
1146 }
1147
1148 let instance = yield* waitForInstance(environmentId, current.id);
1149 let needsDeploy = false;
1150
1151 const observedImage = instance?.source?.image ?? undefined;
1152 const imageChanged =
1153 sourceImage !== undefined && !sameImage(observedImage, sourceImage);
1154 const observedRegion = instance?.region ?? undefined;
1155 const regionChanged =
1156 props.region !== undefined && props.region !== observedRegion;
1157 const sleepOn = instance?.sleepApplication !== false;
1158 const observedStart = instance?.startCommand ?? undefined;
1159 const startChanged =
1160 startCommand !== undefined && observedStart !== startCommand;
1161 if (imageChanged || regionChanged || sleepOn || startChanged) {
1162 yield* serviceInstanceUpdate({
1163 environmentId,
1164 serviceId: current.id,
1165 input: {
1166 ...(imageChanged ? { source: { image: sourceImage } } : {}),
1167 ...(regionChanged ? { region: props.region } : {}),
1168 ...(sleepOn ? { sleepApplication: false } : {}),
1169 ...(startChanged ? { startCommand } : {}),
1170 },
1171 });
1172 needsDeploy = true;
1173 instance = (yield* getInstance(environmentId, current.id)) ?? instance;
1174 }
1175
1176 const envChanged = yield* syncEnv({
1177 projectId,
1178 environmentId,
1179 serviceId: current.id,
1180 desired: variables,
1181 });
1182 if (envChanged) needsDeploy = true;
1183
1184 let volume: CloudInstance | undefined =
1185 output?.volumeInstanceId !== undefined &&
1186 output.volumeInstanceId.length > 0
1187 ? yield* getVolumeByInstanceId(output.volumeInstanceId)
1188 : undefined;
1189 if (volume === undefined && output?.volumeId !== undefined) {
1190 volume = yield* findVolume(
1191 environmentId,
1192 projectId,
1193 (row) => row.volumeId === output.volumeId,
1194 );
1195 }
1196 if (volume === undefined) {
1197 volume = yield* findVolume(
1198 environmentId,
1199 projectId,
1200 (row) =>
1201 (row.serviceId ?? undefined) === current!.id ||
1202 row.volume.name === volumeName,
1203 );
1204 }
1205 if (volume === undefined) {
1206 const created = yield* volumeCreate({
1207 input: {
1208 projectId,
1209 environmentId,
1210 mountPath: MYSQL_MOUNT_PATH,
1211 serviceId: current.id,
1212 ...(props.region !== undefined ? { region: props.region } : {}),
1213 },
1214 });
1215 if (created.name !== volumeName) {
1216 yield* stampVolumeName(created.id, volumeName);
1217 }
1218 volume = yield* waitForVolume(environmentId, projectId, created.id);
1219 }
1220 if (volume === undefined || isGoneVolume(volume)) {
1221 return yield* new MySQLVolumeNotCreated({
1222 name: volumeName,
1223 serviceId: current.id,
1224 });
1225 }
1226 if (volume.volume.name !== volumeName) {
1227 yield* stampVolumeName(volume.volumeId, volumeName);
1228 }
1229 const observedMount = volume.mountPath;
1230 const observedServiceId = volume.serviceId ?? undefined;
1231 const mountChanged = observedMount !== MYSQL_MOUNT_PATH;
1232 const attached = observedServiceId === current.id;
1233 if (mountChanged || !attached) {
1234 yield* volumeInstanceUpdate({
1235 volumeId: volume.volumeId,
1236 environmentId,
1237 input: {
1238 ...(mountChanged ? { mountPath: MYSQL_MOUNT_PATH } : {}),
1239 ...(!attached ? { serviceId: current.id } : {}),
1240 },
1241 });
1242 volume =
1243 (yield* waitForVolume(environmentId, projectId, volume.volumeId)) ??
1244 volume;
1245 needsDeploy = true;
1246 }
1247
1248 let proxy = yield* findProxy(environmentId, current.id, MYSQL_PORT);
1249 if (wantPublic && proxy === undefined) {
1250 const created = yield* tcpProxyCreate({
1251 input: {
1252 applicationPort: MYSQL_PORT,
1253 environmentId,
1254 serviceId: current.id,
1255 },
1256 }).pipe(
1257 Effect.catchTag("RailwayValidationError", () =>
1258 Effect.succeed(undefined),
1259 ),
1260 );
1261 proxy =
1262 created !== undefined && !isGoneProxy(created)
1263 ? created
1264 : yield* findProxy(environmentId, current.id, MYSQL_PORT);
1265 }
1266 if (!wantPublic && proxy !== undefined) {
1267 yield* deleteProxy(proxy.id);
1268 proxy = undefined;
1269 }
1270
1271 if (needsDeploy || instance?.latestDeployment == null) {
1272 yield* serviceInstanceDeployV2({
1273 environmentId,
1274 serviceId: current.id,
1275 }).pipe(Effect.catchTag("RailwayValidationError", () => Effect.void));
1276 }
1277
1278 instance =
1279 (yield* waitForDeployment(environmentId, current.id)) ?? instance;
1280 let finalStatus = instance?.latestDeployment?.status;
1281 // A deployment can wedge in DEPLOYING and never reach SUCCESS — the
1282 // container may serve, but Railway keeps its per-environment operation
1283 // lock and the TCP proxy's routing is not committed. Converge: cancel
1284 // the wedged deployment, redeploy once, and insist on SUCCESS.
1285 if (!deployFailed(finalStatus) && !deployReady(finalStatus)) {
1286 const wedged = instance?.latestDeployment?.id;
1287 if (wedged != null && wedged.length > 0) {
1288 yield* deploymentCancel({ id: wedged }).pipe(
1289 Effect.catchTag("RailwayNotFound", () => Effect.void),
1290 );
1291 }
1292 yield* serviceInstanceDeployV2({
1293 environmentId,
1294 serviceId: current.id,
1295 }).pipe(Effect.catchTag("RailwayValidationError", () => Effect.void));
1296 instance =
1297 (yield* waitForDeployment(environmentId, current.id)) ?? instance;
1298 finalStatus = instance?.latestDeployment?.status;
1299 }
1300 if (deployFailed(finalStatus) || !deployReady(finalStatus)) {
1301 return yield* new MySQLDeployFailed({
1302 serviceId: current.id,
1303 status: finalStatus ?? "failed",
1304 deploymentId: instance?.latestDeployment?.id,
1305 });
1306 }
1307
1308 return toAttrs({
1309 service: current,
1310 instance,
1311 volume,
1312 proxy,
1313 projectId,
1314 environmentId,
1315 user,
1316 password,
1317 database,
1318 });
1319 }),
1320
1321 delete: Effect.fn(function* ({ output }) {
1322 const serviceId = output.serviceId;
1323 const environmentId = output.environmentId;
1324 // Cancel a still-running deployment first: it holds Railway's
1325 // per-environment operation lock ("Cannot delete TCP proxy: an
1326 // operation is already in progress") and can stall the service
1327 // teardown indefinitely. A finished deployment makes this a no-op.
1328 if (environmentId.length > 0 && serviceId.length > 0) {
1329 const instance = yield* getInstance(environmentId, serviceId);
1330 const latest = instance?.latestDeployment;
1331 if (
1332 latest?.id != null &&
1333 latest.id.length > 0 &&
1334 !deployReady(latest.status) &&
1335 !deployFailed(latest.status)
1336 ) {
1337 yield* deploymentCancel({ id: latest.id }).pipe(
1338 Effect.catchTag("RailwayNotFound", () => Effect.void),
1339 );
1340 }
1341 }
1342 // Delete the SERVICE next — its teardown cascades onto the proxies.
1343 if (serviceId.length > 0) {
1344 yield* serviceDelete({ id: serviceId }).pipe(
1345 Effect.catchTag("RailwayNotFound", () => Effect.void),
1346 );
1347 yield* waitUntilDeleted(
1348 "Service",
1349 serviceId,
1350 getById(serviceId).pipe(
1351 Effect.map((service) => service === undefined),
1352 ),
1353 );
1354 }
1355 // Proxies usually disappear with the service; wait for the cascade
1356 // instead of fighting the teardown's lock, then force-delete any
1357 // survivor (which no-ops on NotFound).
1358 if (environmentId.length > 0 && serviceId.length > 0) {
1359 const leftover = yield* listProxies(environmentId, serviceId).pipe(
1360 Effect.repeat({
1361 schedule: Schedule.spaced("3 seconds"),
1362 until: (rows) => rows.length === 0,
1363 times: 10,
1364 }),
1365 );
1366 yield* Effect.forEach(leftover, (proxy) => deleteProxy(proxy.id), {
1367 concurrency: 4,
1368 });
1369 yield* waitUntilDeleted(
1370 "TcpProxy",
1371 serviceId,
1372 listProxies(environmentId, serviceId).pipe(
1373 Effect.map((proxies) => proxies.length === 0),
1374 ),
1375 );
1376 } else if (
1377 output.tcpProxyId !== undefined &&
1378 output.tcpProxyId.length > 0
1379 ) {
1380 yield* deleteProxy(output.tcpProxyId);
1381 }
1382 if (output.volumeId.length > 0) {
1383 yield* volumeDelete({ volumeId: output.volumeId }).pipe(
1384 Effect.catchTag("RailwayNotFound", () => Effect.void),
1385 );
1386 const check =
1387 output.volumeInstanceId.length > 0
1388 ? getVolumeByInstanceId(output.volumeInstanceId).pipe(
1389 Effect.map((instance) => instance === undefined),
1390 )
1391 : Effect.succeed(true);
1392 yield* waitUntilDeleted("Volume", output.volumeId, check);
1393 }
1394 }),
1395 });