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