293 lines · 9.5 KB · TypeScript
RawDownload
/** * WorkspaceFileSystem - Effect service contract for workspace file mutations. * * Owns workspace-root-relative file read/write operations and their associated * safety checks and cache invalidation hooks. * * @module WorkspaceFileSystem */ import type {  ProjectReadFileInput,  ProjectReadFileResult,  ProjectWriteFileInput,  ProjectWriteFileResult,} from "@t3tools/contracts";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as Schema from "effect/Schema"; import * as WorkspaceEntries from "./WorkspaceEntries.ts";import * as WorkspacePaths from "./WorkspacePaths.ts"; const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass<WorkspaceFileSystemOperationError>()(  "WorkspaceFileSystemOperationError",  {    workspaceRoot: Schema.String,    relativePath: Schema.String,    resolvedPath: Schema.String,    operationPath: Schema.String,    operation: Schema.Literals([      "realpath-workspace-root",      "realpath-target",      "open",      "stat",      "read",      "close",      "make-directory",      "write-file",    ]),    cause: Schema.Defect(),  },) {  override get message(): string {    return `Workspace file operation '${this.operation}' failed at '${this.operationPath}' for resolved path '${this.resolvedPath}' (requested as '${this.relativePath}' in '${this.workspaceRoot}').`;  }} export class WorkspaceFilePathEscapeError extends Schema.TaggedErrorClass<WorkspaceFilePathEscapeError>()(  "WorkspaceFilePathEscapeError",  {    workspaceRoot: Schema.String,    relativePath: Schema.String,    resolvedWorkspaceRoot: Schema.String,    resolvedPath: Schema.String,  },) {  override get message(): string {    return `Workspace file '${this.relativePath}' resolves outside workspace root '${this.workspaceRoot}': ${this.resolvedPath}`;  }} export class WorkspacePathNotFileError extends Schema.TaggedErrorClass<WorkspacePathNotFileError>()(  "WorkspacePathNotFileError",  {    workspaceRoot: Schema.String,    relativePath: Schema.String,    resolvedPath: Schema.String,  },) {  override get message(): string {    return `Workspace path '${this.relativePath}' in '${this.workspaceRoot}' is not a file: ${this.resolvedPath}`;  }} export class WorkspaceBinaryFileError extends Schema.TaggedErrorClass<WorkspaceBinaryFileError>()(  "WorkspaceBinaryFileError",  {    workspaceRoot: Schema.String,    relativePath: Schema.String,    resolvedPath: Schema.String,  },) {  override get message(): string {    return `Workspace file '${this.relativePath}' in '${this.workspaceRoot}' is binary and cannot be previewed as text.`;  }} export const WorkspaceFileSystemError = Schema.Union([  WorkspaceFileSystemOperationError,  WorkspaceFilePathEscapeError,  WorkspacePathNotFileError,  WorkspaceBinaryFileError,]);export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; /** Service tag for workspace file operations. */export class WorkspaceFileSystem extends Context.Service<  WorkspaceFileSystem,  {    /** Read a UTF-8 text file relative to the workspace root. */    readonly readFile: (      input: ProjectReadFileInput,    ) => Effect.Effect<      ProjectReadFileResult,      WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError    >;    /**     * Write a file relative to the workspace root.     *     * Creates parent directories as needed and rejects paths that escape the     * workspace root.     */    readonly writeFile: (      input: ProjectWriteFileInput,    ) => Effect.Effect<      ProjectWriteFileResult,      WorkspaceFileSystemError | WorkspacePaths.WorkspacePathOutsideRootError    >;  }>()("t3/workspace/WorkspaceFileSystem") {} export const make = Effect.gen(function* () {  const fileSystem = yield* FileSystem.FileSystem;  const path = yield* Path.Path;  const workspacePaths = yield* WorkspacePaths.WorkspacePaths;  const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;   const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn(    "WorkspaceFileSystem.readFile",  )(function* (input) {    const target = yield* workspacePaths.resolveRelativePathWithinRoot({      workspaceRoot: input.cwd,      relativePath: input.relativePath,    });     const realWorkspaceRoot = yield* fileSystem.realPath(input.cwd).pipe(      Effect.mapError(        (cause) =>          new WorkspaceFileSystemOperationError({            workspaceRoot: input.cwd,            relativePath: input.relativePath,            resolvedPath: target.absolutePath,            operationPath: input.cwd,            operation: "realpath-workspace-root",            cause,          }),      ),    );    const realTargetPath = yield* fileSystem.realPath(target.absolutePath).pipe(      Effect.mapError(        (cause) =>          new WorkspaceFileSystemOperationError({            workspaceRoot: input.cwd,            relativePath: input.relativePath,            resolvedPath: target.absolutePath,            operationPath: target.absolutePath,            operation: "realpath-target",            cause,          }),      ),    );    const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath);    if (      relativeRealPath.startsWith(`..${path.sep}`) ||      relativeRealPath === ".." ||      path.isAbsolute(relativeRealPath)    ) {      return yield* new WorkspaceFilePathEscapeError({        workspaceRoot: input.cwd,        relativePath: input.relativePath,        resolvedWorkspaceRoot: realWorkspaceRoot,        resolvedPath: realTargetPath,      });    }     return yield* Effect.scoped(      Effect.gen(function* () {        const handle = yield* fileSystem.open(realTargetPath, { flag: "r" }).pipe(          Effect.mapError(            (cause) =>              new WorkspaceFileSystemOperationError({                workspaceRoot: input.cwd,                relativePath: input.relativePath,                resolvedPath: realTargetPath,                operationPath: realTargetPath,                operation: "open",                cause,              }),          ),        );        const stat = yield* handle.stat.pipe(          Effect.mapError(            (cause) =>              new WorkspaceFileSystemOperationError({                workspaceRoot: input.cwd,                relativePath: input.relativePath,                resolvedPath: realTargetPath,                operationPath: realTargetPath,                operation: "stat",                cause,              }),          ),        );        if (stat.type !== "File") {          return yield* new WorkspacePathNotFileError({            workspaceRoot: input.cwd,            relativePath: input.relativePath,            resolvedPath: realTargetPath,          });        }         const byteLength = Number(stat.size);        const bytesToRead = Math.min(byteLength, PROJECT_READ_FILE_MAX_BYTES);        const maybeFileBytes = yield* handle.readAlloc(bytesToRead).pipe(          Effect.mapError(            (cause) =>              new WorkspaceFileSystemOperationError({                workspaceRoot: input.cwd,                relativePath: input.relativePath,                resolvedPath: realTargetPath,                operationPath: realTargetPath,                operation: "read",                cause,              }),          ),        );        const fileBytes = Option.getOrElse(maybeFileBytes, () => new Uint8Array());        if (fileBytes.includes(0)) {          return yield* new WorkspaceBinaryFileError({            workspaceRoot: input.cwd,            relativePath: input.relativePath,            resolvedPath: realTargetPath,          });        }         return {          relativePath: target.relativePath,          contents: new TextDecoder("utf-8").decode(fileBytes),          byteLength,          truncated: byteLength > PROJECT_READ_FILE_MAX_BYTES,        };      }),    );  });   const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn(    "WorkspaceFileSystem.writeFile",  )(function* (input) {    const target = yield* workspacePaths.resolveRelativePathWithinRoot({      workspaceRoot: input.cwd,      relativePath: input.relativePath,    });     yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe(      Effect.mapError(        (cause) =>          new WorkspaceFileSystemOperationError({            workspaceRoot: input.cwd,            relativePath: input.relativePath,            resolvedPath: target.absolutePath,            operationPath: path.dirname(target.absolutePath),            operation: "make-directory",            cause,          }),      ),    );    yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe(      Effect.mapError(        (cause) =>          new WorkspaceFileSystemOperationError({            workspaceRoot: input.cwd,            relativePath: input.relativePath,            resolvedPath: target.absolutePath,            operationPath: target.absolutePath,            operation: "write-file",            cause,          }),      ),    );    yield* workspaceEntries.refresh(input.cwd);    return { relativePath: target.relativePath };  });   return WorkspaceFileSystem.of({ readFile, writeFile });}); export const layer = Layer.effect(WorkspaceFileSystem, make);