1import type {
2 EnvironmentId,
3 OrchestrationProjectShell,
4 OrchestrationV2ShellSnapshot,
5 ProjectId,
6 ScopedProjectRef,
7 ServerConfig,
8} from "@t3tools/contracts";
9import { Atom } from "effect/reactivity";
10
11import type { EnvironmentProject } from "./models.ts";
12import { scopeProject } from "./models.ts";
13import { isScratchProject } from "./projects.ts";
14import { type EnvironmentCatalogState, enabledEnvironmentIds } from "./connections.ts";
15import { arrayElementsEqual, parseProjectKey, projectKey, projectRefsEqual } from "./entities.ts";
16
17const EMPTY_PROJECTS: ReadonlyArray<OrchestrationProjectShell> = Object.freeze([]);
18const EMPTY_PROJECT_INDEX: ReadonlyMap<ProjectId, OrchestrationProjectShell> = new Map();
19
20export function createEnvironmentProjectAtoms(input: {
21 readonly catalogValueAtom: Atom.Atom<EnvironmentCatalogState>;
22 readonly snapshotAtom: (
23 environmentId: EnvironmentId,
24 ) => Atom.Atom<OrchestrationV2ShellSnapshot | null>;
25 readonly serverConfigValueAtom?: (
26 environmentId: EnvironmentId,
27 ) => Atom.Atom<Pick<ServerConfig, "scratchWorkspaceRoot"> | null>;
28}) {
29 const environmentProjectsAtom = Atom.family((environmentId: EnvironmentId) =>
30 Atom.make(
31 (get): ReadonlyArray<OrchestrationProjectShell> =>
32 get(input.snapshotAtom(environmentId))?.projects ?? EMPTY_PROJECTS,
33 ).pipe(Atom.withLabel(`environment-projects:${environmentId}`)),
34 );
35
36 const environmentProjectIndexAtom = Atom.family((environmentId: EnvironmentId) =>
37 Atom.make((get): ReadonlyMap<ProjectId, OrchestrationProjectShell> => {
38 const projects = get(environmentProjectsAtom(environmentId));
39 if (projects.length === 0) {
40 return EMPTY_PROJECT_INDEX;
41 }
42 return new Map(projects.map((project) => [project.id, project] as const));
43 }).pipe(Atom.withLabel(`environment-project-index:${environmentId}`)),
44 );
45
46 const environmentProjectRefsAtom = Atom.family((environmentId: EnvironmentId) => {
47 let previous: ReadonlyArray<ScopedProjectRef> = [];
48 return Atom.make((get) => {
49 const next = get(environmentProjectsAtom(environmentId)).map((project) => ({
50 environmentId,
51 projectId: project.id,
52 }));
53 if (projectRefsEqual(previous, next)) {
54 return previous;
55 }
56 previous = next;
57 return next;
58 }).pipe(Atom.withLabel(`environment-project-refs:${environmentId}`));
59 });
60
61 const projectAtomFamily = Atom.family((key: string) => {
62 const ref = parseProjectKey(key);
63 let previousSource: OrchestrationProjectShell | null = null;
64 let previousIsScratch = false;
65 let previousValue: EnvironmentProject | null = null;
66 return Atom.make((get) => {
67 const source = get(environmentProjectIndexAtom(ref.environmentId)).get(ref.projectId) ?? null;
68 const scratch =
69 source !== null &&
70 isScratchProject(
71 source,
72 input.serverConfigValueAtom
73 ? get(input.serverConfigValueAtom(ref.environmentId))?.scratchWorkspaceRoot
74 : null,
75 );
76 if (source === previousSource && scratch === previousIsScratch) {
77 return previousValue;
78 }
79 previousSource = source;
80 previousIsScratch = scratch;
81 previousValue =
82 source === null
83 ? null
84 : {
85 ...scopeProject(ref.environmentId, source),
86 ...(scratch ? { isScratch: true as const } : {}),
87 };
88 return previousValue;
89 }).pipe(Atom.withLabel(`environment-project:${key}`));
90 });
91
92 let previousProjectRefs: ReadonlyArray<ScopedProjectRef> = [];
93 const projectRefsAtom = Atom.make((get) => {
94 const refs: ScopedProjectRef[] = [];
95 for (const environmentId of enabledEnvironmentIds(get(input.catalogValueAtom))) {
96 refs.push(...get(environmentProjectRefsAtom(environmentId)));
97 }
98 if (projectRefsEqual(previousProjectRefs, refs)) {
99 return previousProjectRefs;
100 }
101 previousProjectRefs = refs;
102 return refs;
103 }).pipe(Atom.withLabel("environment-project-refs"));
104
105 let previousProjects: ReadonlyArray<EnvironmentProject> = [];
106 const projectsAtom = Atom.make((get) => {
107 const next = get(projectRefsAtom).flatMap((ref) => {
108 const project = get(projectAtomFamily(projectKey(ref)));
109 return project === null ? [] : [project];
110 });
111 if (arrayElementsEqual(previousProjects, next)) {
112 return previousProjects;
113 }
114 previousProjects = next;
115 return previousProjects;
116 }).pipe(Atom.withLabel("environment-project-list"));
117
118 const projectConfigsReadyAtom = Atom.make((get) => {
119 const catalog = get(input.catalogValueAtom);
120 if (!catalog.isReady) return false;
121 for (const environmentId of enabledEnvironmentIds(catalog)) {
122 if (input.serverConfigValueAtom && get(input.serverConfigValueAtom(environmentId)) === null) {
123 return false;
124 }
125 }
126 return true;
127 }).pipe(Atom.withLabel("environment-project-configs-ready"));
128
129 return {
130 environmentProjectsAtom,
131 environmentProjectIndexAtom,
132 environmentProjectRefsAtom,
133 projectRefsAtom,
134 projectsAtom,
135 projectConfigsReadyAtom,
136 projectAtom: (ref: ScopedProjectRef) => projectAtomFamily(projectKey(ref)),
137 };
138}