1import { waitUntilDeleted } from "./GraphQL.ts";
2import { Query, type UnwrapPlan } from "@distilled.cloud/core/query";
3import {
4 Railway,
5 type Project as RailwayProject,
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 { RailwayEnvironment } from "./Environment.ts";
15import { waitOutCreateRateLimit } from "./transient.ts";
16import {
17 createRailwayName,
18 matchesAlchemyPhysicalName,
19 sanitizeRailwayName,
20} from "./Metadata.ts";
21import type { Providers } from "./Providers.ts";
22
23const projectFields = <E>(project: Query<RailwayProject, E>) => ({
24 id: project.id,
25 name: project.name,
26 description: project.description,
27 workspaceId: project.workspaceId,
28 primaryEnvironmentId: project.primaryEnvironmentId,
29 baseEnvironmentId: project.baseEnvironmentId,
30 deletedAt: project.deletedAt,
31});
32type CloudProject = UnwrapPlan<ReturnType<typeof projectFields>>;
33
34export interface ProjectProps {
35 /**
36 * Project name. Unique per workspace. If omitted, a unique name is
37 * generated from the stack, stage and logical ID. Changing it updates
38 * the project in place.
39 */
40 name?: string;
41 /**
42 * Workspace to create the project in. Defaults to the current token's
43 * workspace (`me.workspace ?? me.workspaces[0]`). Changing it replaces
44 * the project.
45 */
46 workspaceId?: string;
47 /**
48 * Human-readable description. Updates in place.
49 */
50 description?: string;
51 /**
52 * Name of the environment created with the project (Railway's default
53 * is `production`). Create-only; ignored on update.
54 */
55 defaultEnvironmentName?: string;
56}
57
58export type Project = Resource<
59 "Railway.Project",
60 ProjectProps,
61 {
62 /** Railway project id. */
63 projectId: string;
64 /** Physical project name (unique per workspace). */
65 name: string;
66 /** Workspace the project lives in. */
67 workspaceId: string;
68 /** Primary / base environment id created with the project. */
69 environmentId: string;
70 /** Dashboard URL (`https://railway.com/project/{projectId}`). */
71 url: string;
72 },
73 never,
74 Providers
75>;
76
77/**
78 * A Railway.Project is a workspace-scoped namespace. It owns environments
79 * and services. Names are unique per workspace.
80 *
81 * @see https://docs.railway.com/guides/projects
82 *
83 * ### Create a Project
84 * Alchemy generates a unique name unless you pass one. `url` is the
85 * dashboard URL. A production environment is created with the project —
86 * do not recreate it as an Environment resource.
87 *
88 * **Example:** Generated name
89 * ```typescript
90 * const site = yield* Railway.Project("Site");
91 * ```
92 *
93 * :::note
94 * Prefer omitting `name` in tests and CI so names stay unique and
95 * reclaimable.
96 * :::
97 *
98 * ### A stable name
99 * Pass `name` when you need a stable project name. Changing it later
100 * updates the project in place.
101 *
102 * **Example:** Explicit name
103 * ```typescript
104 * const site = yield* Railway.Project("Site", {
105 * name: "my-site",
106 * });
107 * ```
108 *
109 * ### Description
110 * `description` is optional and updates in place.
111 *
112 * **Example:** With description
113 * ```typescript
114 * const site = yield* Railway.Project("Site", {
115 * description: "production web app",
116 * });
117 * ```
118 *
119 * ### Workspace
120 * Workspace defaults to the current token. Pass `workspaceId` to pin it.
121 *
122 * **Example:** Pin a workspace
123 * ```typescript
124 * const site = yield* Railway.Project("Site", {
125 * workspaceId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
126 * });
127 * ```
128 *
129 * :::caution[Changing `workspaceId` replaces the Project]
130 * The Project is created in the new workspace. The old Project is deleted.
131 * :::
132 *
133 * ### Default environment name
134 * `defaultEnvironmentName` is create-only. Railway's default is
135 * `production`. Extra environments (staging) are a separate Environment
136 * resource.
137 *
138 * **Example:** Custom default environment
139 * ```typescript
140 * const site = yield* Railway.Project("Site", {
141 * defaultEnvironmentName: "production",
142 * });
143 * ```
144 *
145 * ### Module-scope declarations
146 * Declare the Project once. Pass it into every child. Resource-valued
147 * props accept the resource or an Effect producing it.
148 *
149 * **Example:** Module-scope Project
150 * ```typescript
151 * // src/project.ts
152 * import * as Railway from "alchemy/Railway";
153 *
154 * export const Site = Railway.Project("Site");
155 * ```
156 *
157 * @resource
158 * @product Project
159 */
160export const Project = Resource<Project>("Railway.Project");
161
162export class ProjectNotCreated extends Data.TaggedError(
163 "Railway.ProjectNotCreated",
164)<{
165 name: string;
166}> {}
167
168/** Workspace id from a get-by-id or list-node Project payload. */
169export const workspaceIdOf = (
170 project: {
171 readonly workspaceId?: string | null;
172 readonly workspace?: { readonly id?: string | null } | null;
173 },
174 fallback?: string,
175): string => project.workspaceId ?? project.workspace?.id ?? fallback ?? "";
176
177/** Primary environment id from a get-by-id or list-node Project payload. */
178export const environmentIdOf = (project: {
179 readonly primaryEnvironmentId?: string | null;
180 readonly baseEnvironmentId?: string | null;
181 readonly baseEnvironment?: { readonly id?: string | null } | null;
182}): string =>
183 project.primaryEnvironmentId ??
184 project.baseEnvironmentId ??
185 project.baseEnvironment?.id ??
186 "";
187
188const toAttrs = (
189 project: CloudProject,
190 fallback?: { name?: string; workspaceId?: string; environmentId?: string },
191): Project["Attributes"] => {
192 const name = project.name || fallback?.name || "";
193 return {
194 projectId: project.id,
195 name,
196 workspaceId: workspaceIdOf(project, fallback?.workspaceId),
197 environmentId: environmentIdOf(project) || fallback?.environmentId || "",
198 url: `https://railway.com/project/${project.id}`,
199 };
200};
201
202const liveEnvironments = (projectId: string, first: number) =>
203 Query.items(
204 Railway.environments({ projectId, first }).pipe(
205 Query.map((env) => ({ id: env.id, deletedAt: env.deletedAt })),
206 ),
207 );
208
209const fillEnvironmentId = (attrs: Project["Attributes"]) => {
210 if (attrs.environmentId.length > 0) return Effect.succeed(attrs);
211 return liveEnvironments(attrs.projectId, 5).pipe(
212 Stream.filter((env) => env.deletedAt == null),
213 Stream.take(1),
214 Stream.runHead,
215 Effect.map((option) =>
216 option._tag === "Some"
217 ? { ...attrs, environmentId: option.value.id }
218 : attrs,
219 ),
220 Effect.catchTag("RailwayNotFound", () => Effect.succeed(attrs)),
221 );
222};
223
224const resolveName = (id: string, name: string | undefined, existing?: string) =>
225 Effect.gen(function* () {
226 if (name !== undefined) return sanitizeRailwayName(name);
227 if (existing !== undefined) return existing;
228 return yield* createRailwayName(id);
229 });
230
231const isGone = (project: CloudProject | undefined) =>
232 project === undefined || project.deletedAt != null;
233
234const readProject = Query.fn((id: string) =>
235 projectFields(Railway.project({ id })),
236);
237
238const getById = (projectId: string) =>
239 readProject(projectId).pipe(
240 Effect.map((project) => (isGone(project) ? undefined : project)),
241 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
242 );
243
244const currentWorkspaceId = Effect.fn(function* () {
245 const env = yield* yield* RailwayEnvironment;
246 return env.workspaceId;
247});
248
249const projectCreate = Query.fn(
250 (input: {
251 name: string;
252 workspaceId: string;
253 description?: string;
254 defaultEnvironmentName?: string;
255 }) => projectFields(Railway.projectCreate({ input })),
256);
257
258export const createProject = (input: {
259 name: string;
260 workspaceId: string;
261 description?: string;
262 defaultEnvironmentName?: string;
263}) =>
264 waitOutCreateRateLimit(
265 projectCreate({
266 name: input.name,
267 workspaceId: input.workspaceId,
268 ...(input.description !== undefined
269 ? { description: input.description }
270 : {}),
271 ...(input.defaultEnvironmentName !== undefined
272 ? { defaultEnvironmentName: input.defaultEnvironmentName }
273 : {}),
274 }),
275 );
276
277const projectUpdate = Query.fn(
278 (id: string, input: { name?: string; description?: string }) =>
279 projectFields(Railway.projectUpdate({ id, input })),
280);
281
282const projectDelete = Query.fn((id: string) => Railway.projectDelete({ id }));
283
284const workspaceProjects = (workspaceId: string) =>
285 Query.items(
286 Railway.projects({ workspaceId, first: 50, includeDeleted: false }).pipe(
287 Query.map(projectFields),
288 ),
289 );
290
291const findByName = (workspaceId: string, name: string) =>
292 workspaceProjects(workspaceId).pipe(
293 Stream.filter((project) => !isGone(project) && project.name === name),
294 Stream.take(1),
295 Stream.runHead,
296 Effect.map((option) => (option._tag === "Some" ? option.value : undefined)),
297 );
298
299/**
300 * Workspace projects Alchemy owns. Distilled `projects.items` plus the
301 * physical-name filter — Railway has no tags. No lock, no retry override.
302 */
303export const ownedProjects = Effect.fn(function* () {
304 const workspaceId = yield* currentWorkspaceId();
305 const projects = yield* workspaceProjects(workspaceId).pipe(
306 Stream.runCollect,
307 );
308 return Array.from(projects)
309 .filter(
310 (project) => !isGone(project) && matchesAlchemyPhysicalName(project.name),
311 )
312 .map((project) => toAttrs(project, { workspaceId }));
313});
314
315export const listOwnedProjects = ownedProjects;
316
317/**
318 * Live environment ids under a project, including the stamped primary.
319 * `list()` walks these so partition environments are not invisible.
320 */
321export const projectEnvironmentIds = (project: {
322 projectId: string;
323 environmentId: string;
324}) =>
325 liveEnvironments(project.projectId, 50).pipe(
326 Stream.filter((env) => env.deletedAt == null),
327 Stream.map((env) => env.id),
328 Stream.runCollect,
329 Effect.map((ids) => {
330 const set = new Set(Array.from(ids));
331 if (project.environmentId.length > 0) {
332 set.add(project.environmentId);
333 }
334 return Array.from(set);
335 }),
336 Effect.catchTag("RailwayNotFound", () =>
337 Effect.succeed(
338 project.environmentId.length > 0 ? [project.environmentId] : [],
339 ),
340 ),
341 );
342
343export const ProjectProvider = () =>
344 Provider.succeed(Project, {
345 stables: ["projectId", "workspaceId", "environmentId"],
346
347 diff: Effect.fn(function* ({ news, output }) {
348 if (news === undefined || !isResolved(news)) return undefined;
349 if (output === undefined) return undefined;
350 const workspaceChanged =
351 news.workspaceId !== undefined &&
352 news.workspaceId !== output.workspaceId;
353 if (workspaceChanged) {
354 return { action: "replace" as const };
355 }
356 return undefined;
357 }),
358
359 read: Effect.fn(function* ({ id, olds, output }) {
360 const name = yield* resolveName(id, olds?.name, output?.name);
361 const workspaceId =
362 output?.workspaceId ??
363 olds?.workspaceId ??
364 (yield* currentWorkspaceId());
365 const found =
366 (output?.projectId !== undefined
367 ? yield* getById(output.projectId)
368 : undefined) ?? (yield* findByName(workspaceId, name));
369 if (found === undefined) return undefined;
370 const attrs = yield* fillEnvironmentId(
371 toAttrs(found, {
372 name,
373 workspaceId,
374 environmentId: output?.environmentId,
375 }),
376 );
377 if (output !== undefined) return attrs;
378 return matchesAlchemyPhysicalName(found.name) ? attrs : Unowned(attrs);
379 }),
380
381 list: listOwnedProjects,
382
383 reconcile: Effect.fn(function* ({ id, news, output }) {
384 const props = news ?? {};
385 const name = yield* resolveName(id, props.name, output?.name);
386 const workspaceId =
387 props.workspaceId ??
388 output?.workspaceId ??
389 (yield* currentWorkspaceId());
390
391 let current: CloudProject | undefined =
392 output?.projectId !== undefined
393 ? yield* getById(output.projectId)
394 : undefined;
395 if (current === undefined) {
396 current = yield* findByName(workspaceId, name);
397 }
398
399 if (current === undefined) {
400 const created = yield* createProject({
401 name,
402 workspaceId,
403 ...(props.description !== undefined
404 ? { description: props.description }
405 : {}),
406 ...(props.defaultEnvironmentName !== undefined
407 ? { defaultEnvironmentName: props.defaultEnvironmentName }
408 : {}),
409 }).pipe(
410 Effect.catchTag("RailwayValidationError", () =>
411 Effect.succeed(undefined),
412 ),
413 );
414 current = created ?? (yield* findByName(workspaceId, name));
415 }
416
417 if (current === undefined || isGone(current)) {
418 return yield* new ProjectNotCreated({ name });
419 }
420
421 const nameChanged = current.name !== name;
422 const observedDescription = current.description ?? undefined;
423 const descriptionChanged =
424 props.description !== undefined &&
425 props.description !== observedDescription;
426 if (nameChanged || descriptionChanged) {
427 current = yield* projectUpdate(current.id, {
428 ...(nameChanged ? { name } : {}),
429 ...(descriptionChanged ? { description: props.description } : {}),
430 });
431 }
432
433 return yield* fillEnvironmentId(
434 toAttrs(current, {
435 name,
436 workspaceId,
437 environmentId: output?.environmentId,
438 }),
439 );
440 }),
441
442 delete: Effect.fn(function* ({ output }) {
443 const projectId = output.projectId;
444 if (projectId.length === 0) return;
445 yield* projectDelete(projectId).pipe(
446 Effect.catchTag("RailwayNotFound", () => Effect.void),
447 );
448 yield* waitUntilDeleted(
449 "Project",
450 projectId,
451 getById(projectId).pipe(Effect.map((project) => project === undefined)),
452 );
453 }),
454 });