History

[codex] Refactor project and workspace Effect services (#3190)

Julius Marminge committed 4 months ago

Commit b0a3a501 parent 79c571755 files · +1211 −1220Browse files

Showing the changes to apps/server/src/workspace/WorkspaceFileSystem.ts (1 of 55 files). Show all

Unified · Split

apps/server/src/workspace/WorkspaceFileSystem.ts+175−0
@@ -0,0 +1,175 @@
1// @effect-diagnostics nodeBuiltinImport:off
2/**
3 * WorkspaceFileSystem - Effect service contract for workspace file mutations.
4 *
5 * Owns workspace-root-relative file read/write operations and their associated
6 * safety checks and cache invalidation hooks.
7 *
8 * @module WorkspaceFileSystem
9 */
10import { open, realpath } from "node:fs/promises";
11
12import type {
13 ProjectReadFileInput,
14 ProjectReadFileResult,
15 ProjectWriteFileInput,
16 ProjectWriteFileResult,
17} from "@t3tools/contracts";
18import * as Context from "effect/Context";
19import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";
21import * as Layer from "effect/Layer";
22import * as Path from "effect/Path";
23import * as Schema from "effect/Schema";
24
25import * as WorkspaceEntries from "./WorkspaceEntries.ts";
26import * as WorkspacePaths from "./WorkspacePaths.ts";
27
28const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024;
29
30export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceFileSystemError>()(
31 "WorkspaceFileSystemError",
32 {
33 cwd: Schema.String,
34 relativePath: Schema.optional(Schema.String),
35 operation: Schema.Literals([
36 "workspaceFileSystem.readFile",
37 "workspaceFileSystem.makeDirectory",
38 "workspaceFileSystem.writeFile",
39 ]),
40 cause: Schema.Defect(),
41 },
42) {
43 override get message(): string {
44 const target = this.relativePath ? `'${this.relativePath}' in '${this.cwd}'` : `'${this.cwd}'`;
45 return `Workspace file operation '${this.operation}' failed for ${target}.`;
46 }
47}
48
49/** Service tag for workspace file operations. */
50export class WorkspaceFileSystem extends Context.Service<
51 WorkspaceFileSystem,
52 {
53 /** Read a UTF-8 text file relative to the workspace root. */
54 readonly readFile: (
55 input: ProjectReadFileInput,
56 ) => Effect.Effect<
57 ProjectReadFileResult,
58 WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
59 >;
60 /**
61 * Write a file relative to the workspace root.
62 *
63 * Creates parent directories as needed and rejects paths that escape the
64 * workspace root.
65 */
66 readonly writeFile: (
67 input: ProjectWriteFileInput,
68 ) => Effect.Effect<
69 ProjectWriteFileResult,
70 WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError
71 >;
72 }
73>()("t3/workspace/WorkspaceFileSystem") {}
74
75export const make = Effect.gen(function* () {
76 const fileSystem = yield* FileSystem.FileSystem;
77 const path = yield* Path.Path;
78 const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
79 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
80
81 const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn(
82 "WorkspaceFileSystem.readFile",
83 )(function* (input) {
84 const target = yield* workspacePaths.resolveRelativePathWithinRoot({
85 workspaceRoot: input.cwd,
86 relativePath: input.relativePath,
87 });
88
89 return yield* Effect.tryPromise({
90 try: async () => {
91 const [realWorkspaceRoot, realTargetPath] = await Promise.all([
92 realpath(input.cwd),
93 realpath(target.absolutePath),
94 ]);
95 const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);
96 if (
97 relativeRealPath.startsWith(`..${path.sep}`) ||
98 relativeRealPath === ".." ||
99 path.isAbsolute(relativeRealPath)
100 ) {
101 throw new Error("Workspace file path resolves outside the project root.");
102 }
103
104 const handle = await open(realTargetPath, "r");
105 try {
106 const stat = await handle.stat();
107 if (!stat.isFile()) {
108 throw new Error("Workspace path is not a file.");
109 }
110 const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES);
111 const buffer = Buffer.alloc(bytesToRead);
112 const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0);
113 const fileBytes = buffer.subarray(0, bytesRead);
114 if (fileBytes.includes(0)) {
115 throw new Error("Binary files cannot be previewed as text.");
116 }
117 const contents = new TextDecoder("utf-8").decode(fileBytes);
118 return {
119 relativePath: target.relativePath,
120 contents,
121 byteLength: stat.size,
122 truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES,
123 };
124 } finally {
125 await handle.close();
126 }
127 },
128 catch: (cause) =>
129 new WorkspaceFileSystemError({
130 cwd: input.cwd,
131 relativePath: input.relativePath,
132 operation: "workspaceFileSystem.readFile",
133 cause,
134 }),
135 });
136 });
137
138 const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(
139 "WorkspaceFileSystem.writeFile",
140 )(function* (input) {
141 const target = yield* workspacePaths.resolveRelativePathWithinRoot({
142 workspaceRoot: input.cwd,
143 relativePath: input.relativePath,
144 });
145
146 yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe(
147 Effect.mapError(
148 (cause) =>
149 new WorkspaceFileSystemError({
150 cwd: input.cwd,
151 relativePath: input.relativePath,
152 operation: "workspaceFileSystem.makeDirectory",
153 cause,
154 }),
155 ),
156 );
157 yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe(
158 Effect.mapError(
159 (cause) =>
160 new WorkspaceFileSystemError({
161 cwd: input.cwd,
162 relativePath: input.relativePath,
163 operation: "workspaceFileSystem.writeFile",
164 cause,
165 }),
166 ),
167 );
168 yield* workspaceEntries.refresh(input.cwd);
169 return { relativePath: target.relativePath };
170 });
171
172 return WorkspaceFileSystem.of({ readFile, writeFile });
173});
174
175export const layer = Layer.effect(WorkspaceFileSystem, make);