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+ */
10+import { open, realpath } from "node:fs/promises";
11+
12+import type {
13+ ProjectReadFileInput,
14+ ProjectReadFileResult,
15+ ProjectWriteFileInput,
16+ ProjectWriteFileResult,
17+} from "@t3tools/contracts";
18+import * as Context from "effect/Context";
19+import * as Effect from "effect/Effect";
20+import * as FileSystem from "effect/FileSystem";
21+import * as Layer from "effect/Layer";
22+import * as Path from "effect/Path";
23+import * as Schema from "effect/Schema";
24+
25+import * as WorkspaceEntries from "./WorkspaceEntries.ts";
26+import * as WorkspacePaths from "./WorkspacePaths.ts";
27+
28+const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024;
29+
30+export 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. */
50+export 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+
75+export 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+
175+export const layer = Layer.effect(WorkspaceFileSystem, make);