| 1 | import { |
| 2 | type EnvironmentId, |
| 3 | ProjectId, |
| 4 | type ProjectReadFileResult, |
| 5 | type ScopedProjectRef, |
| 6 | WS_METHODS, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import * as Crypto from "effect/Crypto"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Option from "effect/Option"; |
| 11 | import * as Predicate from "effect/Predicate"; |
| 12 | import * as Schema from "effect/Schema"; |
| 13 | import * as Stream from "effect/Stream"; |
| 14 | import { Atom, AtomRegistry } from "effect/reactivity"; |
| 15 | |
| 16 | import { |
| 17 | type EnvironmentRpcInput, |
| 18 | type EnvironmentRpcSuccess, |
| 19 | request, |
| 20 | requestGuarded, |
| 21 | } from "../rpc/client.ts"; |
| 22 | import type { EnvironmentProject } from "./models.ts"; |
| 23 | import { |
| 24 | createAtomCommandScheduler, |
| 25 | createEnvironmentCommand, |
| 26 | createEnvironmentRpcCommand, |
| 27 | createEnvironmentRpcQueryAtomFamily, |
| 28 | createEnvironmentRpcSubscriptionAtomFamily, |
| 29 | } from "./runtime.ts"; |
| 30 | import { |
| 31 | type CreateProjectInput, |
| 32 | type DeleteProjectInput, |
| 33 | type UpdateProjectInput, |
| 34 | createProject, |
| 35 | deleteProject, |
| 36 | updateProject, |
| 37 | } from "../operations/commands.ts"; |
| 38 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 39 | import { createFileMetadataAtoms } from "./fileMetadata.ts"; |
| 40 | import { resolveWorkspaceFilePath } from "@t3tools/shared/path"; |
| 41 | |
| 42 | export type { |
| 43 | CreateProjectInput, |
| 44 | DeleteProjectInput, |
| 45 | UpdateProjectInput, |
| 46 | } from "../operations/commands.ts"; |
| 47 | |
| 48 | export interface OptimisticProjectFile { |
| 49 | readonly data: ProjectReadFileResult; |
| 50 | readonly confirmedAgainst: object | null | undefined; |
| 51 | } |
| 52 | |
| 53 | export interface OptimisticProjectFileTarget { |
| 54 | readonly environmentId: EnvironmentId; |
| 55 | readonly cwd: string; |
| 56 | readonly relativePath: string; |
| 57 | } |
| 58 | |
| 59 | function optimisticProjectFileKey(target: OptimisticProjectFileTarget): string { |
| 60 | return JSON.stringify([target.environmentId, target.cwd, target.relativePath]); |
| 61 | } |
| 62 | |
| 63 | function 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. */ |
| 75 | export 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 | |
| 84 | export 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 | } |