1/**
2 * One Railway project for the live test process. Not a stack resource —
3 * tests pass it as `project:` so `stack.destroy()` cannot delete it.
4 *
5 * Named so `matchesAlchemyPhysicalName` still lists it (service `list()`
6 * walks owned projects) and `pnpm nuke` can reclaim it.
7 */
8import { Query } from "@distilled.cloud/core/query";
9import { GraphQLLive, Railway } from "@distilled.cloud/railway";
10import { RailwayAuth } from "@/Railway/AuthProvider.ts";
11import { fromAuthProvider } from "@/Railway/Credentials.ts";
12import { resolveWorkspace } from "@/Railway/Environment.ts";
13import { Environment } from "@/Railway/ProjectEnvironment.ts";
14import { createProject, type Project } from "@/Railway/Project.ts";
15import * as Effect from "effect/Effect";
16import * as Layer from "effect/Layer";
17import * as Stream from "effect/Stream";
18import { SUITE_PROJECT_NAME } from "./suiteProjectName.ts";
19
20export { SUITE_PROJECT_NAME } from "./suiteProjectName.ts";
21
22const toProject = (
23 project: {
24 id: string;
25 name?: string | null;
26 workspaceId?: string | null;
27 workspace?: { id: string } | null;
28 primaryEnvironmentId?: string | null;
29 baseEnvironmentId?: string | null;
30 baseEnvironment?: { id: string } | null;
31 deletedAt?: string | null;
32 },
33 workspaceId: string,
34): Project["Attributes"] => ({
35 projectId: project.id,
36 name: project.name || SUITE_PROJECT_NAME,
37 workspaceId: project.workspaceId ?? project.workspace?.id ?? workspaceId,
38 environmentId:
39 project.primaryEnvironmentId ??
40 project.baseEnvironmentId ??
41 project.baseEnvironment?.id ??
42 "",
43 url: `https://railway.com/project/${project.id}`,
44});
45
46const workspaceProjects = (workspaceId: string) =>
47 Query.items(
48 Railway.projects({ workspaceId, first: 50, includeDeleted: false }).pipe(
49 Query.map((project) => ({
50 id: project.id,
51 name: project.name,
52 workspaceId: project.workspaceId,
53 primaryEnvironmentId: project.primaryEnvironmentId,
54 baseEnvironmentId: project.baseEnvironmentId,
55 deletedAt: project.deletedAt,
56 })),
57 ),
58 );
59
60const readProject = Query.fn((id: string) => {
61 const project = Railway.project({ id });
62 return {
63 id: project.id,
64 name: project.name,
65 workspaceId: project.workspaceId,
66 primaryEnvironmentId: project.primaryEnvironmentId,
67 baseEnvironmentId: project.baseEnvironmentId,
68 };
69});
70
71const projectEnvironments = (projectId: string) =>
72 Query.items(
73 Railway.environments({ projectId, first: 5 }).pipe(
74 Query.map((env) => ({ id: env.id, deletedAt: env.deletedAt })),
75 ),
76 );
77
78const findByName = (workspaceId: string) =>
79 workspaceProjects(workspaceId).pipe(
80 Stream.filter(
81 (project) =>
82 project.deletedAt == null && project.name === SUITE_PROJECT_NAME,
83 ),
84 Stream.take(1),
85 Stream.runHead,
86 Effect.map((option) => (option._tag === "Some" ? option.value : undefined)),
87 );
88
89const acquire = Effect.gen(function* () {
90 const workspace = yield* resolveWorkspace();
91 const existing = yield* findByName(workspace.id);
92 const resolve = (project: Parameters<typeof toProject>[0]) =>
93 Effect.gen(function* () {
94 let attrs = toProject(project, workspace.id);
95 if (attrs.environmentId.length === 0) {
96 const fresh = yield* readProject(attrs.projectId);
97 attrs = toProject(fresh, workspace.id);
98 }
99 if (attrs.environmentId.length > 0) {
100 return attrs;
101 }
102 const env = yield* projectEnvironments(attrs.projectId).pipe(
103 Stream.filter((item) => item.deletedAt == null),
104 Stream.take(1),
105 Stream.runHead,
106 );
107 return env._tag === "Some"
108 ? { ...attrs, environmentId: env.value.id }
109 : attrs;
110 });
111
112 if (existing !== undefined) {
113 return yield* resolve(existing);
114 }
115
116 return yield* createProject({
117 name: SUITE_PROJECT_NAME,
118 workspaceId: workspace.id,
119 description: "alchemy railway live-test suite (shared)",
120 }).pipe(
121 Effect.flatMap((project) => resolve(project)),
122 Effect.catch((error) =>
123 findByName(workspace.id).pipe(
124 Effect.flatMap((found) =>
125 found !== undefined ? resolve(found) : Effect.fail(error),
126 ),
127 ),
128 ),
129 );
130}).pipe(
131 Effect.provide(
132 Layer.mergeAll(
133 GraphQLLive,
134 fromAuthProvider().pipe(Layer.provide(RailwayAuth)),
135 ),
136 ),
137);
138
139/**
140 * Process-cached create-or-get. Yield it inside a test or pass the
141 * Effect as `project:` (resource-valued props accept Effects).
142 */
143export const suiteProject = Effect.runSync(
144 Effect.cached(
145 acquire.pipe(
146 Effect.map((attrs) => attrs as unknown as Project),
147 Effect.orDie,
148 ),
149 ),
150);
151
152/**
153 * Empty extra environment on {@link suiteProject}. Yield inside
154 * `stack.deploy` so destroy deletes the partition. Does not fork
155 * production (`sourceEnvironmentId` omitted).
156 */
157export const suitePartition = Effect.gen(function* () {
158 const project = yield* suiteProject;
159 const environment = yield* Environment("Partition", { project });
160 return { project, environment };
161});