1import { waitUntilDeleted } from "./GraphQL.ts";
2import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
3import {
4 Railway,
5 type Environment as RailwayEnvironment,
6} from "@distilled.cloud/railway";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Stream from "effect/Stream";
10import { Unowned } from "../AdoptPolicy.ts";
11import { isResolved } from "../Diff.ts";
12import * as Provider from "../Provider.ts";
13import { Resource } from "../Resource.ts";
14import {
15 createRailwayEnvironmentName,
16 matchesAlchemyPhysicalName,
17 sanitizeRailwayEnvironmentName,
18} from "./Metadata.ts";
19import { ownedProjects, type Project } from "./Project.ts";
20import type { Providers } from "./Providers.ts";
21import { waitOutCreateRateLimit } from "./transient.ts";
22
23const environmentFields = <E>(env: Query<RailwayEnvironment, E>) => ({
24 id: env.id,
25 name: env.name,
26 projectId: env.projectId,
27 isEphemeral: env.isEphemeral,
28 deletedAt: env.deletedAt,
29});
30type CloudEnvironment = UnwrapPlan<ReturnType<typeof environmentFields>>;
31
32/**
33 * A resource-valued prop: the resource itself, or an Effect that produces
34 * it (so `yield* Project(...)` and `Project(...)` both type-check).
35 */
36type Ref<T> = T | Effect.Effect<T, never, Providers>;
37
38export interface EnvironmentProps {
39 /**
40 * Parent Railway Project. Accepts a `Railway.Project` resource or an
41 * Effect that produces one. Changing it replaces the Environment.
42 */
43 project: Ref<Project>;
44 /**
45 * Environment name. Unique per project. If omitted, a unique name is
46 * generated from the stack, stage and logical ID. Changing it updates
47 * the environment in place via `environmentRename`.
48 */
49 name?: string;
50 /**
51 * Fork this environment from an existing one (`sourceEnvironmentId`).
52 * Create-only; ignored on update. Pass the project's `environmentId` to
53 * copy production's services, volumes, and variables.
54 */
55 sourceEnvironmentId?: string;
56}
57
58export type Environment = Resource<
59 "Railway.Environment",
60 EnvironmentProps,
61 {
62 /** Railway environment id. */
63 environmentId: string;
64 /** Physical environment name (unique per project). */
65 name: string;
66 /** Parent Railway project id. */
67 projectId: string;
68 /** Whether this is an ephemeral (PR) environment. */
69 isEphemeral: boolean;
70 /** Dashboard URL for this environment. */
71 url: string;
72 },
73 never,
74 Providers
75>;
76
77const EnvironmentResource = Resource<Environment>("Railway.Environment");
78
79const resolveEnvironmentProps = (
80 props: EnvironmentProps | Effect.Effect<EnvironmentProps, never, Providers>,
81): Effect.Effect<EnvironmentProps, never, Providers> =>
82 Effect.gen(function* () {
83 const resolved = Effect.isEffect(props) ? yield* props : props;
84 if (globalThis.__ALCHEMY_RUNTIME__) return resolved;
85 const project = Effect.isEffect(resolved.project)
86 ? yield* resolved.project as Effect.Effect<Project, never, Providers>
87 : resolved.project;
88 return { ...resolved, project };
89 });
90
91/**
92 * A Railway.Environment is an extra deploy environment under a Project
93 * (staging, preview, …). The production environment is created with the
94 * Project — do not recreate it as an Environment resource.
95 *
96 * @see https://docs.railway.com/guides/environments
97 *
98 * ### Create an extra environment
99 * Alchemy generates a unique name unless you pass one. Production is
100 * already on the Project as `environmentId`.
101 *
102 * **Example:** Staging next to production
103 * ```typescript
104 * const site = yield* Railway.Project("Site");
105 * const staging = yield* Railway.Environment("Staging", {
106 * project: site,
107 * });
108 * ```
109 *
110 * :::note
111 * Prefer omitting `name` in tests and CI so names stay unique and
112 * reclaimable.
113 * :::
114 *
115 * ### A stable name
116 * Pass `name` when you need a stable environment name (`staging`).
117 * Changing it later updates the environment in place.
118 *
119 * **Example:** Explicit name
120 * ```typescript
121 * const staging = yield* Railway.Environment("Staging", {
122 * project: site,
123 * name: "staging",
124 * });
125 * ```
126 *
127 * ### Fork from production
128 * `sourceEnvironmentId` copies services, volumes, configuration, and
129 * variables from another environment. Create-only.
130 *
131 * **Example:** Fork production
132 * ```typescript
133 * const staging = yield* Railway.Environment("Staging", {
134 * project: site,
135 * sourceEnvironmentId: site.environmentId,
136 * });
137 * ```
138 *
139 * ### Module-scope declarations
140 * Declare the Project once. Pass it into every child. Resource-valued
141 * props accept the resource or an Effect producing it.
142 *
143 * **Example:** Module-scope Environment
144 * ```typescript
145 * // src/project.ts
146 * import * as Railway from "alchemy/Railway";
147 *
148 * export const Site = Railway.Project("Site");
149 * export const Staging = Railway.Environment("Staging", {
150 * project: Site,
151 * });
152 * ```
153 *
154 * @resource
155 * @product Project
156 */
157export const Environment: typeof EnvironmentResource = Object.assign(
158 (
159 id: string,
160 props: EnvironmentProps | Effect.Effect<EnvironmentProps, never, Providers>,
161 ) => EnvironmentResource(id, resolveEnvironmentProps(props)),
162 EnvironmentResource,
163);
164
165export class EnvironmentNotCreated extends Data.TaggedError(
166 "Railway.EnvironmentNotCreated",
167)<{
168 name: string;
169 projectId: string;
170}> {}
171
172export class EnvironmentProjectRequired extends Data.TaggedError(
173 "Railway.EnvironmentProjectRequired",
174)<{
175 message: string;
176}> {}
177
178const toAttrs = (
179 env: CloudEnvironment,
180 fallback?: { name?: string; projectId?: string },
181): Environment["Attributes"] => {
182 const name = env.name || fallback?.name || "";
183 const projectId = env.projectId || fallback?.projectId || "";
184 return {
185 environmentId: env.id,
186 name,
187 projectId,
188 isEphemeral: env.isEphemeral,
189 url: `https://railway.com/project/${projectId}?environmentId=${env.id}`,
190 };
191};
192
193const resolveName = (id: string, name: string | undefined, existing?: string) =>
194 Effect.gen(function* () {
195 if (name !== undefined) return sanitizeRailwayEnvironmentName(name);
196 if (existing !== undefined) return existing;
197 return yield* createRailwayEnvironmentName(id);
198 });
199
200const isGone = (env: CloudEnvironment | undefined) =>
201 env === undefined || env.deletedAt != null;
202
203const projectIdOf = (value: unknown): string | undefined => {
204 if (value === null || typeof value !== "object") return undefined;
205 const rec = value as { projectId?: unknown };
206 return typeof rec.projectId === "string" && rec.projectId.length > 0
207 ? rec.projectId
208 : undefined;
209};
210
211const readEnvironment = Query.fn((id: string, projectId?: string) =>
212 environmentFields(
213 Railway.environment({
214 id,
215 ...(projectId !== undefined ? { projectId } : {}),
216 }),
217 ),
218);
219
220const projectEnvironments = (projectId: string) =>
221 Query.items(
222 Railway.environments({ projectId, first: 50 }).pipe(
223 Query.map(environmentFields),
224 ),
225 );
226
227const environmentCreate = Query.fn(
228 (input: { name: string; projectId: string; sourceEnvironmentId?: string }) =>
229 environmentFields(Railway.environmentCreate({ input })),
230);
231
232const environmentRename = Query.fn((id: string, name: string) =>
233 environmentFields(Railway.environmentRename({ id, input: { name } })),
234);
235
236const environmentDelete = Query.fn((id: string) =>
237 Railway.environmentDelete({ id }),
238);
239
240const getById = (environmentId: string, projectId?: string) =>
241 readEnvironment(environmentId, projectId).pipe(
242 Effect.map((env) => (isGone(env) ? undefined : env)),
243 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
244 );
245
246const findByName = (projectId: string, name: string) =>
247 projectEnvironments(projectId).pipe(
248 Stream.filter((env) => !isGone(env) && env.name === name),
249 Stream.take(1),
250 Stream.runHead,
251 Effect.map((option) => (option._tag === "Some" ? option.value : undefined)),
252 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
253 );
254
255export const EnvironmentProvider = () =>
256 Provider.succeed(Environment, {
257 stables: ["environmentId", "projectId"],
258 nuke: { dependsOn: ["Railway.Project"] },
259
260 diff: Effect.fn(function* ({ news, output }) {
261 if (news === undefined || !isResolved(news)) return undefined;
262 if (output === undefined) return undefined;
263 const nextProjectId = projectIdOf(news.project);
264 if (nextProjectId !== undefined && nextProjectId !== output.projectId) {
265 return { action: "replace" as const };
266 }
267 return undefined;
268 }),
269
270 read: Effect.fn(function* ({ id, olds, output }) {
271 const name = yield* resolveName(id, olds?.name, output?.name);
272 const projectId =
273 output?.projectId ??
274 (olds !== undefined ? projectIdOf(olds.project) : undefined);
275 const found =
276 (output?.environmentId !== undefined
277 ? yield* getById(output.environmentId, output.projectId)
278 : undefined) ??
279 (projectId !== undefined
280 ? yield* findByName(projectId, name)
281 : undefined);
282 if (found === undefined) return undefined;
283 const attrs = toAttrs(found, { name, projectId });
284 if (output !== undefined) return attrs;
285 return matchesAlchemyPhysicalName(found.name) ? attrs : Unowned(attrs);
286 }),
287
288 list: Effect.fn(function* () {
289 const projects = yield* ownedProjects();
290 const rows = yield* Effect.forEach(projects, (project) =>
291 projectEnvironments(project.projectId).pipe(
292 Stream.filter(
293 (env) =>
294 env.deletedAt == null && matchesAlchemyPhysicalName(env.name),
295 ),
296 Stream.runCollect,
297 Effect.map((chunk) =>
298 Array.from(chunk).map((env) => {
299 const projectId = env.projectId || project.projectId;
300 return {
301 environmentId: env.id,
302 name: env.name,
303 projectId,
304 isEphemeral: env.isEphemeral,
305 url: `https://railway.com/project/${projectId}?environmentId=${env.id}`,
306 };
307 }),
308 ),
309 Effect.catchTag("RailwayNotFound", () =>
310 Effect.succeed([] as Environment["Attributes"][]),
311 ),
312 ),
313 );
314 return rows.flat();
315 }),
316
317 reconcile: Effect.fn(function* ({ id, news, output }) {
318 const props = news ?? ({} as EnvironmentProps);
319 const projectId = projectIdOf(props.project) ?? output?.projectId;
320 if (projectId === undefined) {
321 return yield* new EnvironmentProjectRequired({
322 message: "Environment requires a resolved Railway.Project",
323 });
324 }
325 const name = yield* resolveName(id, props.name, output?.name);
326
327 let current: CloudEnvironment | undefined =
328 output?.environmentId !== undefined
329 ? yield* getById(output.environmentId, output.projectId)
330 : undefined;
331 if (current === undefined) {
332 current = yield* findByName(projectId, name);
333 }
334
335 if (current === undefined) {
336 const created = yield* waitOutCreateRateLimit(
337 environmentCreate({
338 name,
339 projectId,
340 ...(props.sourceEnvironmentId !== undefined
341 ? { sourceEnvironmentId: props.sourceEnvironmentId }
342 : {}),
343 }),
344 ).pipe(
345 Effect.catchTag("RailwayValidationError", () =>
346 Effect.succeed(undefined),
347 ),
348 );
349 current = created ?? (yield* findByName(projectId, name));
350 }
351
352 if (current === undefined || isGone(current)) {
353 return yield* new EnvironmentNotCreated({ name, projectId });
354 }
355
356 if (current.name !== name) {
357 current = yield* environmentRename(current.id, name);
358 }
359
360 return toAttrs(current, { name, projectId });
361 }),
362
363 delete: Effect.fn(function* ({ output }) {
364 const environmentId = output.environmentId;
365 if (environmentId.length === 0) return;
366 yield* environmentDelete(environmentId).pipe(
367 Effect.catchTag("RailwayNotFound", () => Effect.void),
368 );
369 yield* waitUntilDeleted(
370 "Environment",
371 environmentId,
372 getById(environmentId, output.projectId).pipe(
373 Effect.map((env) => env === undefined),
374 ),
375 );
376 }),
377 });