1import { environmentVolumes, waitUntilDeleted } from "./GraphQL.ts";
2import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
3import {
4 Railway,
5 type VolumeCreateInput,
6 type VolumeInstance,
7 type VolumeState,
8} from "@distilled.cloud/railway";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import * as Schedule from "effect/Schedule";
12import * as Semaphore from "effect/Semaphore";
13import * as Stream from "effect/Stream";
14import { Unowned } from "../AdoptPolicy.ts";
15import { isResolved } from "../Diff.ts";
16import * as Provider from "../Provider.ts";
17import { Resource } from "../Resource.ts";
18import { createRailwayName, matchesAlchemyPhysicalName } from "./Metadata.ts";
19import { MultipleVolumes } from "./MountVolume.ts";
20import { ownedProjects, type Project } from "./Project.ts";
21import type { Providers } from "./Providers.ts";
22
23const volumeFields = <E>(instance: Query<VolumeInstance, E>) => ({
24 id: instance.id,
25 volumeId: instance.volumeId,
26 environmentId: instance.environmentId,
27 serviceId: instance.serviceId,
28 deletedAt: instance.deletedAt,
29 isPendingDeletion: instance.isPendingDeletion,
30 state: instance.state,
31 mountPath: instance.mountPath,
32 volume: {
33 id: instance.volume.id,
34 name: instance.volume.name,
35 projectId: instance.volume.projectId,
36 },
37 region: instance.region,
38 sizeMB: instance.sizeMB,
39 createdAt: instance.createdAt,
40});
41type CloudInstance = UnwrapPlan<ReturnType<typeof volumeFields>>;
42
43/**
44 * A resource-valued prop: the resource itself, or an Effect that produces
45 * it (so `yield* Project(...)` and `Project(...)` both type-check).
46 */
47type Ref<T> = T | Effect.Effect<T, never, Providers>;
48
49/**
50 * Environment identity a Volume is deployed into. Accepts a
51 * `Railway.Project` (its primary environment), a `Railway.Environment`,
52 * or an `{ environmentId }` stub.
53 */
54export type VolumeEnvironment = {
55 readonly environmentId: string;
56};
57
58/**
59 * Service identity a Volume can attach to at create time. Accepts a
60 * `Railway.Service` or a `{ serviceId }` stub. Subsequent attach is
61 * `MountVolume` — omit `service` to leave the volume disconnected.
62 */
63export type VolumeService = {
64 readonly serviceId: string;
65};
66
67export interface VolumeProps {
68 /**
69 * Parent Railway Project. Changing it replaces the Volume.
70 */
71 project: Ref<Project>;
72 /**
73 * Environment to deploy the volume instance into. Accepts a
74 * `Railway.Project` (primary environment), a `Railway.Environment`, or
75 * `{ environmentId }`. Defaults to the project's primary environment.
76 * Changing it replaces the Volume.
77 */
78 environment?: Ref<VolumeEnvironment>;
79 /**
80 * Path in the container to mount the volume. Updates in place via
81 * `volumeInstanceUpdate`.
82 */
83 mountPath: string;
84 /**
85 * Region for the volume instance (`us-west2`, `us-east4`, …). If
86 * omitted, Railway picks the default. Changing it replaces the Volume.
87 */
88 region?: string;
89 /**
90 * Service to attach the volume to. If omitted, the volume is
91 * disconnected — attach later with MountVolume. Accepts a
92 * `Railway.Service` or `{ serviceId }`. Updating it attaches or moves
93 * the volume in place.
94 */
95 service?: Ref<VolumeService>;
96 /**
97 * Requested provisioned size in megabytes (Railway IaC parity).
98 *
99 * The public GraphQL `VolumeCreateInput` has no `size`/`sizeMB` field,
100 * and `VolumeInstanceUpdateInput` cannot grow a volume — growing is
101 * Pro-dashboard-only. Alchemy records this prop and reports the cloud
102 * plan default on the observed `sizeMB` attribute. Changing it does
103 * not call an API.
104 */
105 sizeMB?: number;
106}
107
108export type Volume = Resource<
109 "Railway.Volume",
110 VolumeProps,
111 {
112 /** Railway volume id. */
113 volumeId: string;
114 /** Volume instance id in the target environment. */
115 volumeInstanceId: string;
116 /** Physical volume name (unique per project). */
117 name: string;
118 /** Parent Railway project id. */
119 projectId: string;
120 /** Environment the instance is deployed in. */
121 environmentId: string;
122 /** Mount path in the container. */
123 mountPath: string;
124 /** Region the instance lives in, if Railway reported one. */
125 region: string | undefined;
126 /** Attached service id, if any. */
127 serviceId: string | undefined;
128 /** Observed instance state (`READY`, `UPDATING`, …). */
129 state: VolumeState | undefined;
130 /**
131 * Observed provisioned size in MB. Railway picks this from the
132 * workspace plan; the public create/update APIs cannot set it.
133 */
134 sizeMB: number;
135 /** RFC3339 creation timestamp. */
136 createdAt: string;
137 },
138 never,
139 Providers
140>;
141
142const resolveVolumeProps = (
143 props: VolumeProps | Effect.Effect<VolumeProps, never, Providers>,
144): Effect.Effect<VolumeProps, never, Providers> =>
145 Effect.gen(function* () {
146 const resolved = Effect.isEffect(props) ? yield* props : props;
147 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
148 const project = Effect.isEffect(resolved.project)
149 ? yield* resolved.project as Effect.Effect<Project, never, Providers>
150 : resolved.project;
151 const environment =
152 resolved.environment === undefined
153 ? undefined
154 : Effect.isEffect(resolved.environment)
155 ? yield* resolved.environment as Effect.Effect<
156 VolumeEnvironment,
157 never,
158 Providers
159 >
160 : resolved.environment;
161 const service =
162 resolved.service === undefined
163 ? undefined
164 : Effect.isEffect(resolved.service)
165 ? yield* resolved.service as Effect.Effect<
166 VolumeService,
167 never,
168 Providers
169 >
170 : resolved.service;
171 return { ...resolved, project, environment, service };
172 });
173
174const VolumeResource = Resource<Volume>("Railway.Volume");
175
176/**
177 * A Railway.Volume is block disk in a Project. Create it disconnected
178 * (no `service`) and attach later with `MountVolume`, or pass `service`
179 * to attach at create time.
180 *
181 * Railway has no labels. Ownership is stamped into the volume name via
182 * `createPhysicalName`. `mountPath` updates in place. Changing `project`,
183 * `environment`, or `region` replaces the Volume.
184 *
185 * @see https://docs.railway.com/guides/volumes
186 * @see https://docs.railway.com/integrations/api/manage-volumes
187 *
188 * ### Create a Volume
189 * Pass a Project and a mount path. Alchemy generates a unique name.
190 * The volume is disconnected until you attach a Service.
191 *
192 * **Example:** Disconnected volume
193 * ```typescript
194 * const site = yield* Railway.Project("Site");
195 * const data = yield* Railway.Volume("Data", {
196 * project: site,
197 * mountPath: "/data",
198 * });
199 * ```
200 *
201 * :::caution[Changing `project` replaces the Volume]
202 * The Volume is created in the new Project. The old Volume is deleted.
203 * :::
204 *
205 * ### Mount path
206 * `mountPath` is the path in the container. Updating it is in place.
207 *
208 * **Example:** Update the mount path
209 * ```typescript
210 * const data = yield* Railway.Volume("Data", {
211 * project: site,
212 * mountPath: "/app/data",
213 * });
214 * ```
215 *
216 * ### Environment
217 * Defaults to the Project's primary environment. Pass a
218 * `Railway.Environment` (or `{ environmentId }`) to target another one.
219 *
220 * **Example:** Extra environment
221 * ```typescript
222 * const staging = yield* Railway.Environment("Staging", { project: site });
223 * const data = yield* Railway.Volume("StagingData", {
224 * project: site,
225 * environment: staging,
226 * mountPath: "/data",
227 * });
228 * ```
229 *
230 * :::caution[Changing `environment` replaces the Volume]
231 * The Volume is created in the new environment. The old Volume is deleted.
232 * :::
233 *
234 * ### Region
235 * Omit `region` to use Railway's default. Changing it replaces the Volume.
236 *
237 * **Example:** Pin a region
238 * ```typescript
239 * const data = yield* Railway.Volume("Data", {
240 * project: site,
241 * mountPath: "/data",
242 * region: "us-west2",
243 * });
244 * ```
245 *
246 * :::caution[Changing `region` replaces the Volume]
247 * Volumes cannot move region. A new Volume is created, then the old one
248 * is deleted.
249 * :::
250 *
251 * ### Size
252 * `sizeMB` on attributes is the observed plan default. Railway's public
253 * `VolumeCreateInput` has no size field; growing is Pro-dashboard-only.
254 * Passing `sizeMB` on props is IaC-parity documentation — it is not
255 * applied.
256 *
257 * **Example:** Observed size
258 * ```typescript
259 * const data = yield* Railway.Volume("Data", {
260 * project: site,
261 * mountPath: "/data",
262 * });
263 * // data.sizeMB is the plan default (e.g. 5120 on Hobby)
264 * ```
265 *
266 * ### Attach to a Service
267 * Pass `service` to attach at create time. Omit it and attach later with
268 * `MountVolume`. One volume per service.
269 *
270 * **Example:** Create-time attach
271 * ```typescript
272 * const api = yield* Railway.Service("Api", {
273 * project: site,
274 * image: "hashicorp/http-echo",
275 * });
276 * const data = yield* Railway.Volume("Data", {
277 * project: site,
278 * mountPath: "/data",
279 * service: api,
280 * });
281 * ```
282 *
283 * :::caution[One volume per service]
284 * A second volume on the same Service fails with
285 * `Railway.MultipleVolumes`. Railway does not give each replica its
286 * own disk.
287 * :::
288 *
289 * ### Backups
290 * Snapshot a mounted volume with {@link VolumeBackup}. Railway only
291 * backups attached volumes. Restore is destructive — see
292 * `restoreVolumeBackup`.
293 *
294 * **Example:** Manual snapshot
295 * ```typescript
296 * const snap = yield* Railway.VolumeBackup("Nightly", { volume: data });
297 * ```
298 *
299 * ### Module-scope declarations
300 * Declare the Project once. Pass it into every child. Resource-valued
301 * props accept the resource or an Effect producing it.
302 *
303 * **Example:** Module-scope Volume
304 * ```typescript
305 * // src/data.ts
306 * import * as Railway from "alchemy/Railway";
307 *
308 * export const Site = Railway.Project("Site");
309 * export const Data = Railway.Volume("Data", {
310 * project: Site,
311 * mountPath: "/data",
312 * });
313 * ```
314 *
315 * @resource
316 * @product Volume
317 */
318export const Volume: typeof VolumeResource = Object.assign(
319 (
320 id: string,
321 props: VolumeProps | Effect.Effect<VolumeProps, never, Providers>,
322 ) => VolumeResource(id, resolveVolumeProps(props)),
323 VolumeResource,
324);
325
326export class VolumeNotCreated extends Data.TaggedError(
327 "Railway.VolumeNotCreated",
328)<{
329 name: string;
330 projectId: string;
331}> {}
332
333export class VolumeProjectRequired extends Data.TaggedError(
334 "Railway.VolumeProjectRequired",
335)<{
336 message: string;
337}> {}
338
339export class VolumeEnvironmentRequired extends Data.TaggedError(
340 "Railway.VolumeEnvironmentRequired",
341)<{
342 message: string;
343}> {}
344
345class VolumePending extends Data.TaggedError("Railway.VolumePending")<{
346 volumeId: string;
347 state: string;
348}> {}
349
350const projectIdOf = (value: unknown): string | undefined => {
351 if (value === null || typeof value !== "object") return undefined;
352 const rec = value as { projectId?: unknown };
353 return typeof rec.projectId === "string" && rec.projectId.length > 0
354 ? rec.projectId
355 : undefined;
356};
357
358const environmentIdOf = (value: unknown): string | undefined => {
359 if (value === null || typeof value !== "object") return undefined;
360 const rec = value as { environmentId?: unknown };
361 return typeof rec.environmentId === "string" && rec.environmentId.length > 0
362 ? rec.environmentId
363 : undefined;
364};
365
366const serviceIdOf = (value: unknown): string | undefined => {
367 if (value === null || typeof value !== "object") return undefined;
368 const rec = value as { serviceId?: unknown };
369 return typeof rec.serviceId === "string" && rec.serviceId.length > 0
370 ? rec.serviceId
371 : undefined;
372};
373
374const goneState = (state: VolumeState | null | undefined) =>
375 state === "DELETED" || state === "DELETING";
376
377const transientState = (state: VolumeState | null | undefined) =>
378 state === "UPDATING" ||
379 state === "MIGRATING" ||
380 state === "MIGRATION_PENDING" ||
381 state === "RESTORING";
382
383const isDeletedAt = (value: string | null | undefined) =>
384 typeof value === "string" && value.length > 0;
385
386const isGone = (instance: CloudInstance | undefined) =>
387 instance === undefined ||
388 isDeletedAt(instance.deletedAt) ||
389 goneState(instance.state);
390
391const toAttrs = (
392 instance: CloudInstance,
393 fallback?: { name?: string },
394): Volume["Attributes"] => ({
395 volumeId: instance.volumeId || instance.volume.id,
396 volumeInstanceId: instance.id,
397 name: instance.volume.name || fallback?.name || "",
398 projectId: instance.volume.projectId,
399 environmentId: instance.environmentId,
400 mountPath: instance.mountPath,
401 region: instance.region ?? undefined,
402 serviceId: instance.serviceId ?? undefined,
403 state: instance.state ?? undefined,
404 sizeMB: instance.sizeMB,
405 createdAt: instance.createdAt,
406});
407
408const resolveName = (id: string, existing?: string) =>
409 Effect.gen(function* () {
410 if (existing !== undefined) return existing;
411 return yield* createRailwayName(id);
412 });
413
414const readVolumeInstance = Query.fn((id: string) =>
415 volumeFields(Railway.volumeInstance({ id })),
416);
417
418const getByInstanceId = (volumeInstanceId: string) =>
419 readVolumeInstance(volumeInstanceId).pipe(
420 Effect.map((instance): CloudInstance | undefined =>
421 isGone(instance) ? undefined : instance,
422 ),
423 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
424 );
425
426const listVolumeInstances = (environmentId: string, projectId: string) =>
427 environmentVolumes(environmentId, projectId, volumeFields).pipe(
428 Effect.map((instances) => instances.filter((node) => !isGone(node))),
429 Effect.catchTag("RailwayNotFound", () =>
430 Effect.succeed([] as CloudInstance[]),
431 ),
432 );
433
434/** Volumes currently attached to a service instance. Used by Service reconcile. */
435export const listServiceVolumes = (
436 environmentId: string,
437 projectId: string,
438 serviceId: string,
439) =>
440 listVolumeInstances(environmentId, projectId).pipe(
441 Effect.map((rows) =>
442 rows.filter((row) => (row.serviceId ?? undefined) === serviceId),
443 ),
444 );
445
446const findInEnvironment = (
447 environmentId: string,
448 projectId: string,
449 match: (instance: CloudInstance) => boolean,
450) =>
451 listVolumeInstances(environmentId, projectId).pipe(
452 Effect.map((instances) => instances.find(match)),
453 );
454
455const volumeInstanceUpdate = Query.fn(
456 (args: Parameters<typeof Railway.volumeInstanceUpdate>[0]) =>
457 Railway.volumeInstanceUpdate(args),
458);
459
460const volumeAttachSlots = new Map<string, Semaphore.Semaphore>();
461
462const withVolumeAttachLock = <A, E, R>(
463 serviceId: string | undefined,
464 effect: Effect.Effect<A, E, R>,
465): Effect.Effect<A, E, R> => {
466 if (serviceId === undefined) return effect;
467 let slot = volumeAttachSlots.get(serviceId);
468 if (slot === undefined) {
469 slot = Semaphore.makeUnsafe(1);
470 volumeAttachSlots.set(serviceId, slot);
471 }
472 return Semaphore.withPermits(slot, 1)(effect);
473};
474
475const assertAttachTarget = Effect.fn(function* (input: {
476 name: string;
477 environmentId: string;
478 projectId: string;
479 serviceId: string;
480 volumeId?: string;
481}) {
482 const others = (yield* listVolumeInstances(
483 input.environmentId,
484 input.projectId,
485 )).filter((row) => {
486 if ((row.serviceId ?? undefined) !== input.serviceId) return false;
487 if (input.volumeId !== undefined && row.volumeId === input.volumeId) {
488 return false;
489 }
490 return true;
491 });
492 if (others.length > 0) {
493 return yield* new MultipleVolumes({
494 name: input.name,
495 paths: others.map((row) => row.mountPath),
496 volumeIds: others.map((row) => row.volumeId),
497 });
498 }
499});
500
501const liveEnvironments = (projectId: string) =>
502 Query.items(
503 Railway.environments({ projectId, first: 50 }).pipe(
504 Query.map((env) => ({ id: env.id, deletedAt: env.deletedAt })),
505 ),
506 );
507
508const listEnvironmentIds = (project: {
509 projectId: string;
510 environmentId: string;
511}) =>
512 liveEnvironments(project.projectId).pipe(
513 Stream.filter((env) => env.deletedAt == null),
514 Stream.map((env) => env.id),
515 Stream.runCollect,
516 Effect.map((ids) => {
517 const set = new Set(Array.from(ids));
518 if (project.environmentId.length > 0) {
519 set.add(project.environmentId);
520 }
521 return Array.from(set);
522 }),
523 Effect.catchTag("RailwayNotFound", () =>
524 Effect.succeed(
525 project.environmentId.length > 0 ? [project.environmentId] : [],
526 ),
527 ),
528 );
529
530const waitUntilSynced = (
531 volumeInstanceId: string,
532 volumeId: string,
533 desired: { mountPath?: string; serviceId?: string },
534) =>
535 getByInstanceId(volumeInstanceId).pipe(
536 Effect.flatMap((instance) => {
537 const mountPending =
538 desired.mountPath !== undefined &&
539 instance?.mountPath !== desired.mountPath;
540 const servicePending =
541 desired.serviceId !== undefined &&
542 (instance?.serviceId ?? undefined) !== desired.serviceId;
543 if (
544 instance === undefined ||
545 transientState(instance.state) ||
546 mountPending ||
547 servicePending
548 ) {
549 return Effect.fail(
550 new VolumePending({
551 volumeId,
552 state: instance?.state ?? "creating",
553 }),
554 );
555 }
556 return Effect.succeed(instance);
557 }),
558 Effect.retry({
559 while: (e) => e._tag === "Railway.VolumePending",
560 times: 10,
561 schedule: Schedule.spaced("3 seconds"),
562 }),
563 Effect.catchTag("Railway.VolumePending", () =>
564 getByInstanceId(volumeInstanceId),
565 ),
566 );
567
568const waitForInstance = (
569 environmentId: string,
570 projectId: string,
571 volumeId: string,
572) =>
573 findInEnvironment(
574 environmentId,
575 projectId,
576 (instance) => instance.volumeId === volumeId,
577 ).pipe(
578 Effect.flatMap((instance) => {
579 if (instance === undefined || transientState(instance.state)) {
580 return Effect.fail(
581 new VolumePending({
582 volumeId,
583 state: instance?.state ?? "creating",
584 }),
585 );
586 }
587 return Effect.succeed(instance);
588 }),
589 Effect.retry({
590 while: (e) => e._tag === "Railway.VolumePending",
591 times: 10,
592 schedule: Schedule.spaced("3 seconds"),
593 }),
594 Effect.catchTag("Railway.VolumePending", () =>
595 findInEnvironment(
596 environmentId,
597 projectId,
598 (instance) => instance.volumeId === volumeId,
599 ),
600 ),
601 );
602
603/**
604 * Attach a volume instance to a service and wait until Railway reports
605 * the mount path and service id. Used by Service/Function reconcile
606 * (`MountVolume`); uploading a build before the attach is READY is
607 * how `railway up` deploys land FAILED.
608 */
609export const attachVolumeToService = Effect.fn(function* (input: {
610 environmentId: string;
611 projectId: string;
612 serviceId: string;
613 volumeId: string;
614 mountPath: string;
615}) {
616 if (input.volumeId.length === 0) return;
617 const observed = yield* findInEnvironment(
618 input.environmentId,
619 input.projectId,
620 (instance) => instance.volumeId === input.volumeId,
621 );
622 const already =
623 observed !== undefined &&
624 (observed.serviceId ?? undefined) === input.serviceId &&
625 observed.mountPath === input.mountPath &&
626 !transientState(observed.state);
627 if (!already) {
628 yield* volumeInstanceUpdate({
629 volumeId: input.volumeId,
630 environmentId: input.environmentId,
631 input: {
632 serviceId: input.serviceId,
633 mountPath: input.mountPath,
634 },
635 }).pipe(Effect.catchTag("RailwayNotFound", () => Effect.void));
636 }
637 const instance =
638 observed ??
639 (yield* waitForInstance(
640 input.environmentId,
641 input.projectId,
642 input.volumeId,
643 ));
644 if (instance === undefined) return;
645 yield* waitUntilSynced(instance.id, input.volumeId, {
646 mountPath: input.mountPath,
647 serviceId: input.serviceId,
648 });
649});
650
651const waitUntilGone = (input: {
652 volumeInstanceId?: string;
653 volumeId: string;
654 environmentId: string;
655 projectId: string;
656}) => {
657 const check =
658 input.volumeInstanceId !== undefined && input.volumeInstanceId.length > 0
659 ? getByInstanceId(input.volumeInstanceId).pipe(
660 Effect.map((instance) => instance === undefined),
661 )
662 : findInEnvironment(
663 input.environmentId,
664 input.projectId,
665 (instance) => instance.volumeId === input.volumeId,
666 ).pipe(Effect.map((instance) => instance === undefined));
667 return waitUntilDeleted("Volume", input.volumeId, check);
668};
669
670const stampName = Query.fn((volumeId: string, name: string) => ({
671 id: Railway.volumeUpdate({ volumeId, input: { name } }).id,
672}));
673
674const volumeCreate = Query.fn((input: VolumeCreateInput) => {
675 const volume = Railway.volumeCreate({ input });
676 return { id: volume.id, name: volume.name };
677});
678
679const volumeDelete = Query.fn((volumeId: string) =>
680 Railway.volumeDelete({ volumeId }),
681);
682
683export const VolumeProvider = () =>
684 Provider.succeed(Volume, {
685 stables: [
686 "volumeId",
687 "volumeInstanceId",
688 "projectId",
689 "environmentId",
690 "createdAt",
691 ],
692 nuke: { dependsOn: ["Railway.Project"] },
693
694 diff: Effect.fn(function* ({ news, output }) {
695 if (news === undefined || !isResolved(news)) return undefined;
696 if (output === undefined) return undefined;
697 const nextProject = projectIdOf(news.project);
698 const projectChanged =
699 nextProject !== undefined && nextProject !== output.projectId;
700 const nextEnv = environmentIdOf(news.environment);
701 const environmentChanged =
702 nextEnv !== undefined && nextEnv !== output.environmentId;
703 const regionChanged =
704 news.region !== undefined && news.region !== output.region;
705 if (projectChanged || environmentChanged || regionChanged) {
706 return { action: "replace" as const };
707 }
708 return undefined;
709 }),
710
711 read: Effect.fn(function* ({ id, olds, output }) {
712 const projectId =
713 output?.projectId ??
714 (olds !== undefined ? projectIdOf(olds.project) : undefined);
715 const environmentId =
716 output?.environmentId ??
717 (olds !== undefined
718 ? (environmentIdOf(olds.environment) ?? environmentIdOf(olds.project))
719 : undefined);
720 const name = yield* resolveName(id, output?.name);
721
722 const byInstance =
723 output?.volumeInstanceId !== undefined &&
724 output.volumeInstanceId.length > 0
725 ? yield* getByInstanceId(output.volumeInstanceId)
726 : undefined;
727 const found =
728 byInstance ??
729 (projectId !== undefined && environmentId !== undefined
730 ? ((output?.volumeId !== undefined
731 ? yield* findInEnvironment(
732 environmentId,
733 projectId,
734 (instance) => instance.volumeId === output.volumeId,
735 )
736 : undefined) ??
737 (yield* findInEnvironment(
738 environmentId,
739 projectId,
740 (instance) => instance.volume.name === name,
741 )))
742 : undefined);
743 if (found === undefined) return undefined;
744 const attrs = toAttrs(found, { name });
745 if (output !== undefined) return attrs;
746 return matchesAlchemyPhysicalName(found.volume.name)
747 ? attrs
748 : Unowned(attrs);
749 }),
750
751 list: Effect.fn(function* () {
752 const projects = yield* ownedProjects();
753 const rows = yield* Effect.forEach(projects, (project) =>
754 liveEnvironments(project.projectId).pipe(
755 Stream.filter((env) => env.deletedAt == null),
756 Stream.runCollect,
757 Effect.flatMap((environments) =>
758 Effect.forEach(environments, (env) =>
759 listVolumeInstances(env.id, project.projectId),
760 ),
761 ),
762 Effect.map((rows) =>
763 rows
764 .flat()
765 .filter((instance) =>
766 matchesAlchemyPhysicalName(instance.volume.name),
767 )
768 .map((instance) => toAttrs(instance)),
769 ),
770 Effect.catchTag("RailwayNotFound", () =>
771 Effect.succeed([] as Volume["Attributes"][]),
772 ),
773 ),
774 );
775 const seen = new Set<string>();
776 const unique: Volume["Attributes"][] = [];
777 for (const row of rows.flat()) {
778 if (seen.has(row.volumeId)) continue;
779 seen.add(row.volumeId);
780 unique.push(row);
781 }
782 return unique;
783 }),
784
785 reconcile: Effect.fn(function* ({ id, news, output }) {
786 const props = news ?? ({} as VolumeProps);
787 const projectId = projectIdOf(props.project) ?? output?.projectId;
788 if (projectId === undefined) {
789 return yield* new VolumeProjectRequired({
790 message: "Volume requires a resolved Railway.Project",
791 });
792 }
793 const environmentId =
794 environmentIdOf(props.environment) ??
795 environmentIdOf(props.project) ??
796 output?.environmentId;
797 if (environmentId === undefined) {
798 return yield* new VolumeEnvironmentRequired({
799 message:
800 "Volume requires a Railway environment (pass environment or a Project with environmentId)",
801 });
802 }
803 const name = yield* resolveName(id, output?.name);
804 const desiredServiceId =
805 props.service !== undefined ? serviceIdOf(props.service) : undefined;
806
807 let current: CloudInstance | undefined =
808 output?.volumeInstanceId !== undefined &&
809 output.volumeInstanceId.length > 0
810 ? yield* getByInstanceId(output.volumeInstanceId)
811 : undefined;
812 if (current === undefined && output?.volumeId !== undefined) {
813 current = yield* findInEnvironment(
814 environmentId,
815 projectId,
816 (instance) => instance.volumeId === output.volumeId,
817 );
818 }
819 if (current === undefined) {
820 current = yield* findInEnvironment(
821 environmentId,
822 projectId,
823 (instance) => instance.volume.name === name,
824 );
825 }
826
827 current = yield* withVolumeAttachLock(
828 desiredServiceId,
829 Effect.gen(function* () {
830 if (desiredServiceId !== undefined) {
831 yield* assertAttachTarget({
832 name,
833 environmentId,
834 projectId,
835 serviceId: desiredServiceId,
836 volumeId: current?.volumeId,
837 });
838 }
839
840 if (current === undefined) {
841 const created = yield* volumeCreate({
842 projectId,
843 environmentId,
844 mountPath: props.mountPath,
845 ...(props.region !== undefined ? { region: props.region } : {}),
846 ...(desiredServiceId !== undefined
847 ? { serviceId: desiredServiceId }
848 : {}),
849 }).pipe(
850 Effect.catchTag("RailwayInternalError", (error) =>
851 desiredServiceId === undefined
852 ? Effect.fail(error)
853 : assertAttachTarget({
854 name,
855 environmentId,
856 projectId,
857 serviceId: desiredServiceId,
858 volumeId: current?.volumeId,
859 }).pipe(Effect.flatMap(() => Effect.fail(error))),
860 ),
861 );
862 if (created.name !== name) {
863 yield* stampName(created.id, name);
864 }
865 current = yield* waitForInstance(
866 environmentId,
867 projectId,
868 created.id,
869 );
870 }
871
872 if (current === undefined || isGone(current)) {
873 return yield* new VolumeNotCreated({ name, projectId });
874 }
875
876 if (current.volume.name !== name) {
877 yield* stampName(current.volumeId, name);
878 current =
879 (yield* getByInstanceId(current.id)) ??
880 (yield* findInEnvironment(
881 environmentId,
882 projectId,
883 (instance) => instance.volumeId === current!.volumeId,
884 )) ??
885 current;
886 }
887
888 const mountChanged = current.mountPath !== props.mountPath;
889 const observedServiceId = current.serviceId ?? undefined;
890 const serviceChanged =
891 desiredServiceId !== undefined &&
892 desiredServiceId !== observedServiceId;
893 if (mountChanged || serviceChanged) {
894 yield* volumeInstanceUpdate({
895 volumeId: current.volumeId,
896 environmentId,
897 input: {
898 ...(mountChanged ? { mountPath: props.mountPath } : {}),
899 ...(serviceChanged ? { serviceId: desiredServiceId } : {}),
900 },
901 });
902 current =
903 (yield* waitUntilSynced(current.id, current.volumeId, {
904 ...(mountChanged ? { mountPath: props.mountPath } : {}),
905 ...(serviceChanged ? { serviceId: desiredServiceId } : {}),
906 })) ?? current;
907 }
908
909 return current;
910 }),
911 );
912
913 if (current === undefined || isGone(current)) {
914 return yield* new VolumeNotCreated({ name, projectId });
915 }
916
917 return toAttrs(current, { name });
918 }),
919
920 delete: Effect.fn(function* ({ output }) {
921 const volumeId = output.volumeId;
922 if (volumeId.length === 0) return;
923 yield* volumeDelete(volumeId).pipe(
924 Effect.catchTag("RailwayNotFound", () => Effect.void),
925 );
926 yield* waitUntilGone({
927 volumeInstanceId: output.volumeInstanceId,
928 volumeId,
929 environmentId: output.environmentId,
930 projectId: output.projectId,
931 });
932 }),
933 });