History

refactor workspace reads to Effect FileSystem

Cursor Agent committed 3 months ago

Commit 51f34031 parent fdca1542 files · +122 −113Browse files

Unified · Split

2 files
  1. apps/server/src/workspace/WorkspaceFileSystem.test.ts +48 −29
  2. apps/server/src/workspace/WorkspaceFileSystem.ts +74 −84
apps/server/src/workspace/WorkspaceFileSystem.test.ts+48−29
@@ -1,9 +1,11 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { it, describe, expect } from "@effect/vitest";2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
6import * as Path from "effect/Path";7import * as Path from "effect/Path";
8import * as PlatformError from "effect/PlatformError";
7 9
8import * as ServerConfig from "../config.ts";10import * as ServerConfig from "../config.ts";
9import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";11import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
@@ -64,7 +66,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 54 unmodified lines
64 relativePath: "src/index.ts",66 relativePath: "src/index.ts",
65 });67 });
66 68
67 expect(result).toEqual({69 assert.deepStrictEqual(result, {
68 relativePath: "src/index.ts",70 relativePath: "src/index.ts",
69 contents: "export const answer = 42;\n",71 contents: "export const answer = 42;\n",
70 byteLength: 26,72 byteLength: 26,
@@ -82,7 +84,8 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 11 unmodified lines
82 .readFile({ cwd, relativePath: "../escape.md" })84 .readFile({ cwd, relativePath: "../escape.md" })
83 .pipe(Effect.flip);85 .pipe(Effect.flip);
84 86
85 expect(error.message).toContain(87 assert.include(
88 error.message,
86 "Workspace file path must be relative to the project root: ../escape.md",89 "Workspace file path must be relative to the project root: ../escape.md",
87 );90 );
88 }),91 }),
@@ -107,14 +110,14 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 18 unmodified lines
107 const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd);110 const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd);
108 const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt"));111 const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt"));
109 112
110 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError);113 assert.strictEqual(error._tag, "WorkspaceFilePathEscapeError");
111 expect(error).toMatchObject({114 assert.deepInclude(error, {
112 workspaceRoot: cwd,115 workspaceRoot: cwd,
113 relativePath: "linked-secret.txt",116 relativePath: "linked-secret.txt",
114 resolvedWorkspaceRoot,117 resolvedWorkspaceRoot,
115 resolvedPath,118 resolvedPath,
116 });119 });
117 expect("cause" in error).toBe(false);120 assert.notProperty(error, "cause");
118 }),121 }),
119 );122 );
120 123
@@ -131,13 +134,13 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 10 unmodified lines
131 .pipe(Effect.flip);134 .pipe(Effect.flip);
132 const resolvedPath = yield* fileSystem.realPath(path.join(cwd, "src"));135 const resolvedPath = yield* fileSystem.realPath(path.join(cwd, "src"));
133 136
134 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError);137 assert.strictEqual(error._tag, "WorkspacePathNotFileError");
135 expect(error).toMatchObject({138 assert.deepInclude(error, {
136 workspaceRoot: cwd,139 workspaceRoot: cwd,
137 relativePath: "src",140 relativePath: "src",
138 resolvedPath,141 resolvedPath,
139 });142 });
140 expect("cause" in error).toBe(false);143 assert.notProperty(error, "cause");
141 }),144 }),
142 );145 );
143 146
@@ -155,14 +158,31 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 11 unmodified lines
155 .pipe(Effect.flip);158 .pipe(Effect.flip);
156 const resolvedPath = yield* fileSystem.realPath(absolutePath);159 const resolvedPath = yield* fileSystem.realPath(absolutePath);
157 160
158 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceBinaryFileError);161 assert.strictEqual(error._tag, "WorkspaceBinaryFileError");
159 expect(error).toMatchObject({162 assert.deepInclude(error, {
160 workspaceRoot: cwd,163 workspaceRoot: cwd,
161 relativePath: "asset.bin",164 relativePath: "asset.bin",
162 resolvedPath,165 resolvedPath,
163 });166 });
164 expect("cause" in error).toBe(false);167 assert.notProperty(error, "cause");
165 expect("contents" in error).toBe(false);168 assert.notProperty(error, "contents");
169 }),
170 );
171
172 it.effect("reads at most the preview limit", () =>
173 Effect.gen(function* () {
174 const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
175 const cwd = yield* makeTempDir;
176 yield* writeTextFile(cwd, "large.txt", "a".repeat(1024 * 1024 + 1));
177
178 const result = yield* workspaceFileSystem.readFile({
179 cwd,
180 relativePath: "large.txt",
181 });
182
183 assert.strictEqual(result.contents.length, 1024 * 1024);
184 assert.strictEqual(result.byteLength, 1024 * 1024 + 1);
185 assert.isTrue(result.truncated);
166 }),186 }),
167 );187 );
168 188
@@ -177,16 +197,16 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 8 unmodified lines
177 .readFile({ cwd, relativePath: "missing.txt" })197 .readFile({ cwd, relativePath: "missing.txt" })
178 .pipe(Effect.flip);198 .pipe(Effect.flip);
179 199
180 expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFileSystemOperationError);200 assert.strictEqual(error._tag, "WorkspaceFileSystemOperationError");
181 expect(error).toMatchObject({201 assert.deepInclude(error, {
182 workspaceRoot: cwd,202 workspaceRoot: cwd,
183 relativePath: "missing.txt",203 relativePath: "missing.txt",
184 resolvedPath,204 resolvedPath,
185 operationPath: resolvedPath,205 operationPath: resolvedPath,
186 operation: "realpath-target",206 operation: "realpath-target",
187 });207 });
188 expect(error.cause).toBeInstanceOf(Error);208 assert.instanceOf(error.cause, PlatformError.PlatformError);
189 expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT");209 assert.strictEqual((error.cause as PlatformError.PlatformError).reason._tag, "NotFound");
190 }),210 }),
191 );211 );
192 });212 });
@@ -207,8 +227,8 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 14 unmodified lines
207 .readFileString(path.join(cwd, "plans/effect-rpc.md"))227 .readFileString(path.join(cwd, "plans/effect-rpc.md"))
208 .pipe(Effect.orDie);228 .pipe(Effect.orDie);
209 229
210 expect(result).toEqual({ relativePath: "plans/effect-rpc.md" });230 assert.deepStrictEqual(result, { relativePath: "plans/effect-rpc.md" });
211 expect(saved).toBe("# Plan\n");231 assert.strictEqual(saved, "# Plan\n");
212 }),232 }),
213 );233 );
214 234
@@ -220,8 +240,8 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 5 unmodified lines
220 yield* writeTextFile(cwd, "src/existing.ts", "export {};\n");240 yield* writeTextFile(cwd, "src/existing.ts", "export {};\n");
221 241
222 const beforeWrite = yield* workspaceEntries.list({ cwd });242 const beforeWrite = yield* workspaceEntries.list({ cwd });
223 expect(beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md")).toBe(243 assert.isFalse(
224 false,244 beforeWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md"),
225 );245 );
226 246
227 yield* workspaceFileSystem.writeFile({247 yield* workspaceFileSystem.writeFile({
@@ -231,10 +251,10 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 3 unmodified lines
231 });251 });
232 252
233 const afterWrite = yield* workspaceEntries.list({ cwd });253 const afterWrite = yield* workspaceEntries.list({ cwd });
234 expect(afterWrite.entries).toEqual(254 assert.isTrue(
235 expect.arrayContaining([expect.objectContaining({ path: "plans/effect-rpc.md" })]),255 afterWrite.entries.some((entry) => entry.path === "plans/effect-rpc.md"),
236 );256 );
237 expect(afterWrite.truncated).toBe(false);257 assert.isFalse(afterWrite.truncated);
238 }),258 }),
239 );259 );
240 260
@@ -253,15 +273,14 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i · 12 unmodified lines
253 })273 })
254 .pipe(Effect.flip);274 .pipe(Effect.flip);
255 275
256 expect(error.message).toContain(276 assert.include(
277 error.message,
257 "Workspace file path must be relative to the project root: ../escape.md",278 "Workspace file path must be relative to the project root: ../escape.md",
258 );279 );
259 280
260 const escapedPath = path.resolve(cwd, "..", "escape.md");281 const escapedPath = path.resolve(cwd, "..", "escape.md");
261 const escapedStat = yield* fileSystem282 const escapedStat = yield* fileSystem.stat(escapedPath).pipe(Effect.option);
262 .stat(escapedPath)283 assert.isTrue(Option.isNone(escapedStat));
263 .pipe(Effect.orElseSucceed(() => null));
264 expect(escapedStat).toBeNull();
265 }),284 }),
266 );285 );
267 });286 });
apps/server/src/workspace/WorkspaceFileSystem.ts+74−84
@@ -1,4 +1,3 @@
1// @effect-diagnostics nodeBuiltinImport:off
2/**1/**
3 * WorkspaceFileSystem - Effect service contract for workspace file mutations.2 * WorkspaceFileSystem - Effect service contract for workspace file mutations.
4 *3 *
@@ -7,7 +6,6 @@ · 2 unmodified lines
7 *6 *
8 * @module WorkspaceFileSystem7 * @module WorkspaceFileSystem
9 */8 */
10import * as NodeFSP from "node:fs/promises";
11 9
12import type {10import type {
13 ProjectReadFileInput,11 ProjectReadFileInput,
@@ -19,6 +17,7 @@ import * as Context from "effect/Context"; · 5 unmodified lines
19import * as Effect from "effect/Effect";17import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";18import * as FileSystem from "effect/FileSystem";
21import * as Layer from "effect/Layer";19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
22import * as Path from "effect/Path";21import * as Path from "effect/Path";
23import * as Schema from "effect/Schema";22import * as Schema from "effect/Schema";
24 23
@@ -140,30 +139,32 @@ export const make = Effect.gen(function* () { · 115 unmodified lines
140 relativePath: input.relativePath,139 relativePath: input.relativePath,
141 });140 });
142 141
143 const realWorkspaceRoot = yield* Effect.tryPromise({142 const realWorkspaceRoot = yield* fileSystem.realPath(input.cwd).pipe(
144 try: () => NodeFSP.realpath(input.cwd),143 Effect.mapError(
145 catch: (cause) =>144 (cause) =>
146 new WorkspaceFileSystemOperationError({145 new WorkspaceFileSystemOperationError({
147 workspaceRoot: input.cwd,146 workspaceRoot: input.cwd,
148 relativePath: input.relativePath,147 relativePath: input.relativePath,
149 resolvedPath: target.absolutePath,148 resolvedPath: target.absolutePath,
150 operationPath: input.cwd,149 operationPath: input.cwd,
151 operation: "realpath-workspace-root",150 operation: "realpath-workspace-root",
152 cause,151 cause,
153 }),152 }),
154 });153 ),
155 const realTargetPath = yield* Effect.tryPromise({154 );
156 try: () => NodeFSP.realpath(target.absolutePath),155 const realTargetPath = yield* fileSystem.realPath(target.absolutePath).pipe(
157 catch: (cause) =>156 Effect.mapError(
158 new WorkspaceFileSystemOperationError({157 (cause) =>
159 workspaceRoot: input.cwd,158 new WorkspaceFileSystemOperationError({
160 relativePath: input.relativePath,159 workspaceRoot: input.cwd,
161 resolvedPath: target.absolutePath,160 relativePath: input.relativePath,
162 operationPath: target.absolutePath,161 resolvedPath: target.absolutePath,
163 operation: "realpath-target",162 operationPath: target.absolutePath,
164 cause,163 operation: "realpath-target",
165 }),164 cause,
166 });165 }),
166 ),
167 );
167 const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);168 const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);
168 if (169 if (
169 relativeRealPath.startsWith(`..${path.sep}`) ||170 relativeRealPath.startsWith(`..${path.sep}`) ||
@@ -178,24 +179,24 @@ export const make = Effect.gen(function* () { · 8 unmodified lines
178 });179 });
179 }180 }
180 181
181 return yield* Effect.acquireUseRelease(182 return yield* Effect.scoped(
182 Effect.tryPromise({183 Effect.gen(function* () {
183 try: () => NodeFSP.open(realTargetPath, "r"),184 const handle = yield* fileSystem.open(realTargetPath, { flag: "r" }).pipe(
184 catch: (cause) =>185 Effect.mapError(
185 new WorkspaceFileSystemOperationError({186 (cause) =>
186 workspaceRoot: input.cwd,187 new WorkspaceFileSystemOperationError({
187 relativePath: input.relativePath,188 workspaceRoot: input.cwd,
188 resolvedPath: realTargetPath,189 relativePath: input.relativePath,
189 operationPath: realTargetPath,190 resolvedPath: realTargetPath,
190 operation: "open",191 operationPath: realTargetPath,
191 cause,192 operation: "open",
192 }),193 cause,
193 }),194 }),
194 (handle) =>195 ),
195 Effect.gen(function* () {196 );
196 const stat = yield* Effect.tryPromise({197 const stat = yield* handle.stat.pipe(
197 try: () => handle.stat(),198 Effect.mapError(
198 catch: (cause) =>199 (cause) =>
199 new WorkspaceFileSystemOperationError({200 new WorkspaceFileSystemOperationError({
200 workspaceRoot: input.cwd,201 workspaceRoot: input.cwd,
201 relativePath: input.relativePath,202 relativePath: input.relativePath,
@@ -204,20 +205,21 @@ export const make = Effect.gen(function* () { · 2 unmodified lines
204 operation: "stat",205 operation: "stat",
205 cause,206 cause,
206 }),207 }),
208 ),
209 );
210 if (stat.type !== "File") {
211 return yield* new WorkspacePathNotFileError({
212 workspaceRoot: input.cwd,
213 relativePath: input.relativePath,
214 resolvedPath: realTargetPath,
207 });215 });
208 if (!stat.isFile()) {216 }
209 return yield* new WorkspacePathNotFileError({
210 workspaceRoot: input.cwd,
211 relativePath: input.relativePath,
212 resolvedPath: realTargetPath,
213 });
214 }
215 217
216 const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES);218 const byteLength = Number(stat.size);
217 const buffer = Buffer.alloc(bytesToRead);219 const bytesToRead = Math.min(byteLength, PROJECT_READ_FILE_MAX_BYTES);
218 const { bytesRead } = yield* Effect.tryPromise({220 const maybeFileBytes = yield* handle.readAlloc(bytesToRead).pipe(
219 try: () => handle.read(buffer, 0, bytesToRead, 0),221 Effect.mapError(
220 catch: (cause) =>222 (cause) =>
221 new WorkspaceFileSystemOperationError({223 new WorkspaceFileSystemOperationError({
222 workspaceRoot: input.cwd,224 workspaceRoot: input.cwd,
223 relativePath: input.relativePath,225 relativePath: input.relativePath,
@@ -226,36 +228,24 @@ export const make = Effect.gen(function* () { · 2 unmodified lines
226 operation: "read",228 operation: "read",
227 cause,229 cause,
228 }),230 }),
231 ),
232 );
233 const fileBytes = Option.getOrElse(maybeFileBytes, () => new Uint8Array());
234 if (fileBytes.includes(0)) {
235 return yield* new WorkspaceBinaryFileError({
236 workspaceRoot: input.cwd,
237 relativePath: input.relativePath,
238 resolvedPath: realTargetPath,
229 });239 });
230 const fileBytes = buffer.subarray(0, bytesRead);240 }
231 if (fileBytes.includes(0)) {
232 return yield* new WorkspaceBinaryFileError({
233 workspaceRoot: input.cwd,
234 relativePath: input.relativePath,
235 resolvedPath: realTargetPath,
236 });
237 }
238 241
239 return {242 return {
240 relativePath: target.relativePath,243 relativePath: target.relativePath,
241 contents: new TextDecoder("utf-8").decode(fileBytes),244 contents: new TextDecoder("utf-8").decode(fileBytes),
242 byteLength: stat.size,245 byteLength,
243 truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES,246 truncated: byteLength > PROJECT_READ_FILE_MAX_BYTES,
244 };247 };
245 }),248 }),
246 (handle) =>
247 Effect.tryPromise({
248 try: () => handle.close(),
249 catch: (cause) =>
250 new WorkspaceFileSystemOperationError({
251 workspaceRoot: input.cwd,
252 relativePath: input.relativePath,
253 resolvedPath: realTargetPath,
254 operationPath: realTargetPath,
255 operation: "close",
256 cause,
257 }),
258 }),
259 );249 );
260 });250 });
261 251