1import {
2 type EnvironmentId,
3 ProjectId,
4 type ProjectReadFileResult,
5 type ScopedProjectRef,
6 WS_METHODS,
7} from "@t3tools/contracts";
8import * as SecureRandom from "effect/crypto/SecureRandom";
9import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";
11import * as Predicate from "effect/Predicate";
12import * as Schema from "effect/Schema";
13import * as Stream from "effect/Stream";
14import { Atom, AtomRegistry } from "effect/reactivity";
15
16import {
17 type EnvironmentRpcInput,
18 type EnvironmentRpcSuccess,
19 request,
20 requestGuarded,
21} from "../rpc/client.ts";
22import type { EnvironmentProject } from "./models.ts";
23import {
24 createAtomCommandScheduler,
25 createEnvironmentCommand,
26 createEnvironmentRpcCommand,
27 createEnvironmentRpcQueryAtomFamily,
28} from "./runtime.ts";
29import { createProject, deleteProject, updateProject } from "../operations/commands.ts";
30import type { EnvironmentRegistry } from "../connection/registry.ts";
31import { createFileMetadataAtoms } from "./fileMetadata.ts";
32import { resolveWorkspaceFilePath } from "@t3tools/shared/path";
33
34export type {
35 CreateProjectInput,
36 DeleteProjectInput,
37 UpdateProjectInput,
38} from "../operations/commands.ts";
39
40export interface OptimisticProjectFile {
41 readonly data: ProjectReadFileResult;
42 readonly confirmedAgainst: object | null | undefined;
43}
44
45export interface OptimisticProjectFileTarget {
46 readonly environmentId: EnvironmentId;
47 readonly cwd: string;
48 readonly relativePath: string;
49}
50
51function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string {
52 return JSON.stringify([target.environmentId, target.cwd, target.relativePath]);
53}
54
55function writtenFilePaths(
56 input: EnvironmentRpcInput<typeof WS_METHODS.projectsWriteFile>,
57 result: EnvironmentRpcSuccess<typeof WS_METHODS.projectsWriteFile>,
58) {
59 return new Set(
60 [result.relativePath, input.relativePath].map((path) =>
61 resolveWorkspaceFilePath(path, input.cwd),
62 ),
63 );
64}
65
66export class ScratchProjectNotLoadedError extends Schema.TaggedError<ScratchProjectNotLoadedError>()(
67 "ScratchProjectNotLoadedError",
68 { projectId: ProjectId },
69) {
70 override get message(): string {
71 return "The folder for threads without a project has not reached this device yet. Try again.";
72 }
73}
74
75export function createProjectEnvironmentAtoms<R, E>(
76 runtime: Atom.AtomRuntime<EnvironmentRegistry | SecureRandom.SecureRandom | R, E>,
77 options: {
78 readonly projectAtom: (ref: ScopedProjectRef) => Atom.Atom<EnvironmentProject | null>;
79 },
80) {
81 const fileMetadata = createFileMetadataAtoms(runtime);
82 const projectScheduler = createAtomCommandScheduler();
83 const fileScheduler = createAtomCommandScheduler();
84 const optimisticFileFamily = Atom.family((key: string) =>
85 Atom.make<OptimisticProjectFile | null>(null).pipe(
86 Atom.withLabel(`environment-data:projects:optimistic-file:${key}`),
87 ),
88 );
89 const projectConcurrency = {
90 mode: "serial" as const,
91 key: ({ environmentId, input }: { environmentId: string; input: { projectId: string } }) =>
92 JSON.stringify([environmentId, input.projectId]),
93 };
94 return {
95 fileMetadata: fileMetadata.metadata,
96 fileMetadataThread: fileMetadata.retainThread,
97 invalidateFileMetadata: createEnvironmentCommand(runtime, {
98 label: "environment-data:filesystem:invalidate-metadata",
99 execute: (input: { paths: ReadonlyArray<string> }, registry, environmentId) =>
100 Effect.forEach([...new Set(input.paths)], (path) =>
101 fileMetadata
102 .invalidate(path)
103 .pipe(
104 Effect.tap(() =>
105 Effect.sync(() => fileMetadata.refreshPath(environmentId, path, registry)),
106 ),
107 ),
108 ),
109 }),
110 searchEntries: createEnvironmentRpcQueryAtomFamily(runtime, {
111 label: "environment-data:projects:search-entries",
112 tag: WS_METHODS.projectsSearchEntries,
113 staleTimeMs: 15_000,
114 execute: (input) =>
115 fileMetadata.seedAfterRead(
116 request(WS_METHODS.projectsSearchEntries, input),
117 (result, isCurrent) => fileMetadata.rememberEntries(input.cwd, result.entries, isCurrent),
118 ),
119 }),
120 listEntries: createEnvironmentRpcQueryAtomFamily(runtime, {
121 label: "environment-data:projects:list-entries",
122 tag: WS_METHODS.projectsListEntries,
123 staleTimeMs: 30_000,
124 idleTtlMs: 5 * 60_000,
125 execute: (input) =>
126 fileMetadata.seedAfterRead(
127 request(WS_METHODS.projectsListEntries, input),
128 (result, isCurrent) => fileMetadata.rememberEntries(input.cwd, result.entries, isCurrent),
129 ),
130 }),
131 listIgnoredFiles: createEnvironmentRpcQueryAtomFamily(runtime, {
132 label: "environment-data:projects:list-ignored-files",
133 tag: WS_METHODS.projectsListIgnoredFiles,
134 staleTimeMs: 15_000,
135 }),
136 readFile: createEnvironmentRpcQueryAtomFamily(runtime, {
137 label: "environment-data:projects:read-file",
138 tag: WS_METHODS.projectsReadFile,
139 staleTimeMs: 30_000,
140 idleTtlMs: 5 * 60_000,
141 execute: (input) =>
142 fileMetadata.seedAfterRead(
143 request(WS_METHODS.projectsReadFile, input),
144 (result, isCurrent) =>
145 result.exists === false
146 ? Effect.void
147 : fileMetadata.rememberFile(input.cwd, result, isCurrent),
148 ),
149 }),
150 optimisticFile: (target: OptimisticProjectFileTarget) =>
151 optimisticFileFamily(optimisticProjectFileKey(target)),
152 create: createEnvironmentCommand(runtime, {
153 label: "environment-data:commands:project:create",
154 execute: createProject,
155 scheduler: projectScheduler,
156 concurrency: projectConcurrency,
157 }),
158 update: createEnvironmentCommand(runtime, {
159 label: "environment-data:commands:project:update",
160 execute: updateProject,
161 scheduler: projectScheduler,
162 concurrency: projectConcurrency,
163 }),
164 delete: createEnvironmentCommand(runtime, {
165 label: "environment-data:commands:project:delete",
166 execute: deleteProject,
167 scheduler: projectScheduler,
168 concurrency: projectConcurrency,
169 }),
170 // Finds or creates the environment's Scratch project and resolves once the
171 // project is in the client store, since drafts key off its stored path.
172 openScratch: createEnvironmentCommand(runtime, {
173 label: "environment-data:projects:open-scratch",
174 execute: (
175 input: EnvironmentRpcInput<typeof WS_METHODS.projectsEnsureScratch>,
176 registry,
177 environmentId,
178 ) =>
179 request(WS_METHODS.projectsEnsureScratch, input).pipe(
180 Effect.flatMap(({ projectId }) =>
181 AtomRegistry.toStream(registry, options.projectAtom({ environmentId, projectId })).pipe(
182 Stream.filter(Predicate.isNotNull),
183 Stream.runHead,
184 Effect.timeoutOption("10 seconds"),
185 Effect.map(Option.flatten),
186 Effect.flatMap(
187 Option.match({
188 onSome: Effect.succeed,
189 onNone: () => Effect.fail(new ScratchProjectNotLoadedError({ projectId })),
190 }),
191 ),
192 ),
193 ),
194 ),
195 scheduler: projectScheduler,
196 concurrency: { mode: "serial", key: ({ environmentId }) => environmentId },
197 }),
198 createNew: createEnvironmentRpcCommand(runtime, {
199 label: "environment-data:projects:create-new",
200 tag: WS_METHODS.projectsCreateNew,
201 scheduler: projectScheduler,
202 concurrency: { mode: "serial", key: ({ environmentId }) => environmentId },
203 }),
204 createFromThread: createEnvironmentRpcCommand(runtime, {
205 label: "environment-data:projects:create-from-thread",
206 tag: WS_METHODS.projectsCreateFromThread,
207 scheduler: projectScheduler,
208 concurrency: { mode: "serial", key: ({ environmentId }) => environmentId },
209 }),
210 writeFile: createEnvironmentRpcCommand(runtime, {
211 label: "environment-data:projects:write-file",
212 tag: WS_METHODS.projectsWriteFile,
213 scheduler: fileScheduler,
214 concurrency: {
215 mode: "serial",
216 key: ({ environmentId, input }) =>
217 JSON.stringify([environmentId, input.cwd, input.relativePath]),
218 },
219 execute: (input) =>
220 requestGuarded(WS_METHODS.projectsWriteFile, input).pipe(
221 Effect.tap((result) =>
222 Effect.forEach(writtenFilePaths(input, result), fileMetadata.invalidate),
223 ),
224 ),
225 onSuccess: ({ environmentId, input }, registry, result) =>
226 Effect.sync(() => {
227 for (const path of writtenFilePaths(input, result))
228 fileMetadata.refreshPath(environmentId, path, registry);
229 }),
230 }),
231 };
232}