1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Binding from "../Binding.ts";
5import type { Resource } from "../Resource.ts";
6import type { Volume } from "./Volume.ts";
7
8export interface MountVolumeOptions {
9 /**
10 * Absolute path inside the container the Volume is mounted at
11 * (e.g. `/data`).
12 */
13 path: string;
14}
15
16/**
17 * Runtime view of a Volume mounted into a {@link Service} or
18 * `Railway.Function`: the path inside the container.
19 */
20export interface MountedVolume {
21 /** Mount path inside the container (same value as {@link MountVolumeOptions.path}). */
22 path: string;
23}
24
25/**
26 * A volume mount injected onto a {@link Service} via {@link MountVolume}.
27 * Service reconcile attaches it with `volumeInstanceUpdate`.
28 */
29export interface MountSpec {
30 /** Railway volume id (not the instance id). */
31 volumeId: string;
32 /** Absolute path inside the container. */
33 path: string;
34}
35
36// Pass `volume.volumeId` through as-is. At bind time it is an Output,
37// never a materialized string — `typeof === "string"` would store `""`
38// and every downstream guard would skip the mount. `Input<Binding>`
39// resolves the Output before reconcile.
40const volumeIdOf = (volume: Volume) => volume.volumeId ?? "";
41
42const RAILWAY_BIND_HOST_TYPES = new Set([
43 "Railway.Service",
44 "Railway.Function",
45]);
46
47/**
48 * True for a Railway compute host that accepts {@link ServiceBinding}
49 * (`Railway.Service` or {@link Function}).
50 */
51export const isRailwayHost = (
52 value: unknown,
53): value is Resource<string, any, any, ServiceBinding> =>
54 typeof value === "object" &&
55 value !== null &&
56 RAILWAY_BIND_HOST_TYPES.has((value as { Type?: string }).Type ?? "");
57
58/**
59 * Binding contract accepted by {@link Service} and
60 * `Railway.Function` for mounted volumes and injected env.
61 */
62export interface ServiceBinding {
63 env?: Record<string, any>;
64 mounts?: MountSpec[];
65}
66
67/**
68 * Railway allows one volume per service. Two `MountVolume`s, or a
69 * second {@link Volume} attached via `service`, is this error.
70 */
71export class MultipleVolumes extends Data.TaggedError(
72 "Railway.MultipleVolumes",
73)<{
74 name: string;
75 paths: readonly string[];
76 volumeIds: readonly string[];
77}> {}
78
79const distinctMounts = (mounts: readonly MountSpec[]): MountSpec[] => {
80 const seen = new Set<string>();
81 const out: MountSpec[] = [];
82 for (const mount of mounts) {
83 if (mount.volumeId.length === 0) continue;
84 const key = `${mount.volumeId}:${mount.path}`;
85 if (seen.has(key)) continue;
86 seen.add(key);
87 out.push(mount);
88 }
89 return out;
90};
91
92/** At most one volume on a host. */
93export const assertHostDisk = (input: {
94 name: string;
95 mounts: readonly MountSpec[];
96}): Effect.Effect<void, MultipleVolumes> => {
97 const mounts = distinctMounts(input.mounts);
98 if (mounts.length <= 1) return Effect.void;
99 return new MultipleVolumes({
100 name: input.name,
101 paths: mounts.map((mount) => mount.path),
102 volumeIds: mounts.map((mount) => mount.volumeId),
103 });
104};
105
106/**
107 * Mount a Railway.Volume into a {@link Service} or `Railway.Function`.
108 *
109 * `yield* Railway.MountVolume(volume, { path: "/data" })` inside a
110 * Service/Function impl registers `{ mounts: [{ volumeId, path }] }` on
111 * the host. Reconcile attaches the volume via `volumeInstanceUpdate`.
112 *
113 * Railway allows **one volume per service**. A second mount is
114 * `Railway.MultipleVolumes`. Railway does not give each replica its
115 * own disk.
116 *
117 *
118 * ### Mount into a Service
119 * Yield `MountVolume` inside init. Provide {@link MountVolumeLive}.
120 * At runtime you get `disk.path`.
121 *
122 * **Example:** Bind a path
123 * ```typescript
124 * export default class Api extends Railway.Service<Api>()(
125 * "Api",
126 * { project: Site, main: import.meta.url },
127 * Effect.gen(function* () {
128 * const disk = yield* Railway.MountVolume(Data, { path: "/data" });
129 * const fs = yield* FileSystem.FileSystem;
130 * return {
131 * fetch: Effect.gen(function* () {
132 * const text = yield* fs.readFileString(`${disk.path}/hello.txt`);
133 * return HttpServerResponse.text(text);
134 * }),
135 * };
136 * }).pipe(Effect.provide(Railway.MountVolumeLive)),
137 * ) {}
138 * ```
139 *
140 * :::caution[One volume per service]
141 * Railway does not attach a disk per replica. Two {@link MountVolume}s
142 * on one host fail with {@link MultipleVolumes}.
143 * :::
144 *
145 * @binding
146 * @product Volume
147 */
148export interface MountVolume extends Binding.Service<
149 MountVolume,
150 "Railway.MountVolume",
151 (volume: Volume, options: MountVolumeOptions) => Effect.Effect<MountedVolume>
152> {}
153
154export const MountVolume = Binding.Service<MountVolume>("Railway.MountVolume");
155
156export const MountVolumeLive = Layer.effect(
157 MountVolume,
158 Effect.succeed(
159 Effect.fn(function* (volume: Volume, options: MountVolumeOptions) {
160 if (!globalThis.__ALCHEMY_RUNTIME__) {
161 const host = yield* Binding.Host;
162 if (isRailwayHost(host)) {
163 yield* host.bind`Railway.MountVolume(${options.path})`({
164 mounts: [{ volumeId: volumeIdOf(volume), path: options.path }],
165 });
166 }
167 }
168 return { path: options.path };
169 }),
170 ),
171);