branch:
memory.ts
14692 bytesRaw
import { InMemoryFs } from "./fs/in-memory-fs";
import type {
  CpOptions,
  FileSystem,
  FsStat,
  InitialFiles,
  MkdirOptions,
  RmOptions
} from "./fs/interface";
import type {
  StateArchiveCreateResult,
  StateArchiveExtractResult,
  StateArchiveEntry,
  StateApplyEditsOptions,
  StateApplyEditsResult,
  StateBackend,
  StateCapabilities,
  StateCopyOptions,
  StateDirent,
  StateEdit,
  StateEditInstruction,
  StateEditPlan,
  StateFileDetection,
  StateFileSearchResult,
  StateFindEntry,
  StateFindOptions,
  StateHashOptions,
  StateJsonUpdateOperation,
  StateJsonUpdateResult,
  StateJsonWriteOptions,
  StateMkdirOptions,
  StateMoveOptions,
  StateCompressionResult,
  StateReplaceInFilesOptions,
  StateReplaceInFilesResult,
  StateReplaceResult,
  StateRmOptions,
  StateSearchOptions,
  StateStat,
  StateTreeNode,
  StateTreeOptions,
  StateTreeSummary
} from "./backend";
import {
  buildTar,
  buildTree,
  detectFile as detectFileFromBytes,
  extractTar,
  findInTree,
  gzipBytes,
  gunzipBytes,
  hashBytes,
  listTar,
  queryJsonValue,
  summarizeTree,
  updateJsonValue,
  type TarInputEntry
} from "./extras";
import {
  applyTextEdits,
  collectFileReplaceResults,
  collectFileSearchResults,
  diffContent,
  parseJsonFileContent,
  planTextEdits,
  planToStateEdits,
  replaceTextContent,
  searchTextContent,
  stateDirent,
  stringifyJsonFileContent,
  toStateStat
} from "./helpers";

// ── FileSystemStateBackend ────────────────────────────────────────────
//
// The single StateBackend implementation. Accepts any FileSystem —
// pass `new InMemoryFs()` for ephemeral in-memory state or a
// `WorkspaceFileSystem` wrapping a durable Workspace.

export interface FileSystemStateBackendOptions {
  files?: InitialFiles;
  fs?: FileSystem;
}

export class FileSystemStateBackend implements StateBackend {
  readonly fs: FileSystem;

  constructor(fsOrOptions: FileSystem | FileSystemStateBackendOptions = {}) {
    if (isFileSystem(fsOrOptions)) {
      this.fs = fsOrOptions;
    } else {
      this.fs = fsOrOptions.fs ?? new InMemoryFs(fsOrOptions.files);
    }
  }

  async getCapabilities(): Promise<StateCapabilities> {
    return {
      chmod: true,
      utimes: true,
      hardLinks: true
    };
  }

  async readFile(path: string): Promise<string> {
    return this.fs.readFile(path);
  }

  async readFileBytes(path: string): Promise<Uint8Array> {
    return this.fs.readFileBytes(path);
  }

  async writeFile(path: string, content: string): Promise<void> {
    await this.fs.writeFile(path, content);
  }

  async writeFileBytes(path: string, content: Uint8Array): Promise<void> {
    await this.fs.writeFileBytes(path, content);
  }

  async appendFile(path: string, content: string | Uint8Array): Promise<void> {
    await this.fs.appendFile(path, content);
  }

  async readJson(path: string): Promise<unknown> {
    return parseJsonFileContent(await this.readFile(path), path);
  }

  async writeJson(
    path: string,
    value: unknown,
    options?: StateJsonWriteOptions
  ): Promise<void> {
    await this.writeFile(
      path,
      stringifyJsonFileContent(value, path, options?.spaces)
    );
  }

  async queryJson(path: string, query: string): Promise<unknown> {
    return queryJsonValue(await this.readJson(path), query);
  }

  async updateJson(
    path: string,
    operations: StateJsonUpdateOperation[]
  ): Promise<StateJsonUpdateResult> {
    const current = await this.readJson(path);
    const updated = updateJsonValue(current, operations, path);
    await this.writeFile(path, updated.content);
    return updated;
  }

  async exists(path: string): Promise<boolean> {
    return this.fs.exists(path);
  }

  async stat(path: string): Promise<StateStat | null> {
    return this.tryStat(() => this.fs.stat(path));
  }

  async lstat(path: string): Promise<StateStat | null> {
    return this.tryStat(() => this.fs.lstat(path));
  }

  async mkdir(path: string, options?: StateMkdirOptions): Promise<void> {
    await this.fs.mkdir(path, options as MkdirOptions | undefined);
  }

  async readdir(path: string): Promise<string[]> {
    return this.fs.readdir(path);
  }

  async readdirWithFileTypes(path: string): Promise<StateDirent[]> {
    const entries = await this.fs.readdirWithFileTypes(path);
    return sortDirents(entries.map((e) => stateDirent(e.name, e.type)));
  }

  async find(
    path: string,
    options?: StateFindOptions
  ): Promise<StateFindEntry[]> {
    return findInTree(path, this.createTreeOps(), options);
  }

  async walkTree(
    path: string,
    options?: StateTreeOptions
  ): Promise<StateTreeNode> {
    return buildTree(path, this.createTreeOps(), options);
  }

  async summarizeTree(
    path: string,
    options?: StateTreeOptions
  ): Promise<StateTreeSummary> {
    return summarizeTree(path, this.createTreeOps(), options);
  }

  async searchText(
    path: string,
    query: string,
    options?: StateSearchOptions
  ): Promise<ReturnType<typeof searchTextContent>> {
    return searchTextContent(await this.readFile(path), query, options);
  }

  async searchFiles(
    pattern: string,
    query: string,
    options?: StateSearchOptions
  ): Promise<StateFileSearchResult[]> {
    const paths = await this.getFilePaths(pattern);
    return collectFileSearchResults(
      paths,
      this.readFile.bind(this),
      (content) => searchTextContent(content, query, options)
    );
  }

  async replaceInFile(
    path: string,
    search: string,
    replacement: string,
    options?: StateSearchOptions
  ): Promise<StateReplaceResult> {
    const current = await this.readFile(path);
    const result = replaceTextContent(current, search, replacement, options);
    if (result.replaced > 0) {
      await this.writeFile(path, result.content);
    }
    return result;
  }

  async replaceInFiles(
    pattern: string,
    search: string,
    replacement: string,
    options?: StateReplaceInFilesOptions
  ): Promise<StateReplaceInFilesResult> {
    const paths = await this.getFilePaths(pattern);
    return collectFileReplaceResults(
      paths,
      this.readFile.bind(this),
      this.writeFile.bind(this),
      this.deleteFile.bind(this),
      search,
      replacement,
      options
    );
  }

  async rm(path: string, options?: StateRmOptions): Promise<void> {
    await this.fs.rm(path, options as RmOptions | undefined);
  }

  async cp(
    src: string,
    dest: string,
    options?: StateCopyOptions
  ): Promise<void> {
    await this.fs.cp(src, dest, options as CpOptions | undefined);
  }

  async mv(
    src: string,
    dest: string,
    options?: StateMoveOptions
  ): Promise<void> {
    const stat = await this.stat(src);
    if (stat?.type === "directory" && !options?.recursive) {
      throw new Error(
        `EISDIR: cannot move directory without recursive: ${src}`
      );
    }
    await this.fs.mv(src, dest);
  }

  async symlink(target: string, linkPath: string): Promise<void> {
    await this.fs.symlink(target, linkPath);
  }

  async readlink(path: string): Promise<string> {
    return this.fs.readlink(path);
  }

  async realpath(path: string): Promise<string> {
    return this.fs.realpath(path);
  }

  async resolvePath(base: string, path: string): Promise<string> {
    return this.fs.resolvePath(base, path);
  }

  async glob(pattern: string): Promise<string[]> {
    return this.fs.glob(pattern);
  }

  async diff(pathA: string, pathB: string): Promise<string> {
    const [before, after] = await Promise.all([
      this.readFile(pathA),
      this.readFile(pathB)
    ]);
    return diffContent(before, after, pathA, pathB);
  }

  async diffContent(path: string, newContent: string): Promise<string> {
    return diffContent(await this.readFile(path), newContent, path, path);
  }

  async createArchive(
    path: string,
    sources: string[]
  ): Promise<StateArchiveCreateResult> {
    const entries = await this.collectArchiveEntries(sources);
    const tar = buildTar(entries);
    await this.writeFileBytes(path, tar);
    return {
      path,
      entries: entries.map((entry) => ({
        path: entry.path,
        type: entry.type,
        size: entry.type === "file" ? entry.bytes.byteLength : 0
      })),
      bytesWritten: tar.byteLength
    };
  }

  async listArchive(path: string): Promise<StateArchiveEntry[]> {
    return listTar(await this.readFileBytes(path));
  }

  async extractArchive(
    path: string,
    destination: string
  ): Promise<StateArchiveExtractResult> {
    const entries = extractTar(await this.readFileBytes(path));
    for (const entry of entries) {
      const destPath =
        destination === "/" ? `/${entry.path}` : `${destination}/${entry.path}`;
      if (entry.type === "directory") {
        await this.mkdir(destPath, { recursive: true });
      } else if (entry.bytes) {
        await this.writeFileBytes(destPath, entry.bytes);
      }
    }
    return {
      destination,
      entries: entries.map((entry) => ({
        path: entry.path,
        type: entry.type,
        size: entry.bytes?.byteLength ?? 0
      }))
    };
  }

  async compressFile(
    path: string,
    destination?: string
  ): Promise<StateCompressionResult> {
    const dest = destination ?? `${path}.gz`;
    const bytes = await gzipBytes(await this.readFileBytes(path));
    await this.writeFileBytes(dest, bytes);
    return { path, destination: dest, bytesWritten: bytes.byteLength };
  }

  async decompressFile(
    path: string,
    destination?: string
  ): Promise<StateCompressionResult> {
    const dest = destination ?? path.replace(/\.gz$/i, "");
    const bytes = await gunzipBytes(await this.readFileBytes(path));
    await this.writeFileBytes(dest, bytes);
    return { path, destination: dest, bytesWritten: bytes.byteLength };
  }

  async hashFile(path: string, options?: StateHashOptions): Promise<string> {
    return hashBytes(await this.readFileBytes(path), options);
  }

  async detectFile(path: string): Promise<StateFileDetection> {
    return detectFileFromBytes(path, await this.readFileBytes(path));
  }

  async removeTree(path: string): Promise<void> {
    await this.fs.rm(path, { recursive: true, force: true });
  }

  async copyTree(src: string, dest: string): Promise<void> {
    await this.fs.cp(src, dest, { recursive: true });
  }

  async moveTree(src: string, dest: string): Promise<void> {
    await this.fs.mv(src, dest);
  }

  async planEdits(
    instructions: StateEditInstruction[]
  ): Promise<StateEditPlan> {
    return planTextEdits(instructions, async (path) => {
      try {
        return await this.readFile(path);
      } catch (error) {
        if (isEnoent(error)) return null;
        throw error;
      }
    });
  }

  async applyEditPlan(
    plan: StateEditPlan,
    options?: StateApplyEditsOptions
  ): Promise<StateApplyEditsResult> {
    return this.applyEdits(planToStateEdits(plan), options);
  }

  async applyEdits(
    edits: StateEdit[],
    options?: StateApplyEditsOptions
  ): Promise<StateApplyEditsResult> {
    return applyTextEdits(
      edits,
      async (path) => {
        try {
          return await this.readFile(path);
        } catch (error) {
          if (isEnoent(error)) return null;
          throw error;
        }
      },
      this.writeFile.bind(this),
      this.deleteFile.bind(this),
      options
    );
  }

  // ── Private helpers ───────────────────────────────────────────────

  private async tryStat(
    getStat: () => Promise<FsStat>
  ): Promise<StateStat | null> {
    try {
      return toStateStat(await getStat());
    } catch (error) {
      if (isEnoent(error)) return null;
      throw error;
    }
  }

  private async getFilePaths(pattern: string): Promise<string[]> {
    const paths = await this.glob(pattern);
    const files: string[] = [];
    for (const path of paths) {
      const stat = await this.lstat(path);
      if (stat?.type === "file") {
        files.push(path);
      }
    }
    return files;
  }

  private async deleteFile(path: string): Promise<void> {
    await this.fs.rm(path, { force: true });
  }

  private createTreeOps() {
    return {
      lstat: this.lstat.bind(this),
      readdirWithFileTypes: this.readdirWithFileTypes.bind(this),
      resolvePath: this.resolvePath.bind(this)
    };
  }

  private async collectArchiveEntries(
    sources: string[]
  ): Promise<TarInputEntry[]> {
    const entries: TarInputEntry[] = [];
    for (const source of sources) {
      const tree = await this.walkTree(source);
      await appendArchiveNode(tree, entries, this.readFileBytes.bind(this));
    }
    return entries;
  }
}

// ── Factory helpers ───────────────────────────────────────────────────

/** @deprecated Use `new FileSystemStateBackend()` or `createMemoryStateBackend()` */
export type MemoryStateBackendOptions = FileSystemStateBackendOptions;

/** @deprecated Use `FileSystemStateBackend` */
export const MemoryStateBackend = FileSystemStateBackend;

export function createMemoryStateBackend(
  options: FileSystemStateBackendOptions = {}
): FileSystemStateBackend {
  return new FileSystemStateBackend(options);
}

// ── Utilities ─────────────────────────────────────────────────────────

function isFileSystem(value: unknown): value is FileSystem {
  return (
    typeof value === "object" &&
    value !== null &&
    "readFile" in value &&
    "writeFile" in value &&
    "glob" in value
  );
}

function isEnoent(error: unknown): boolean {
  return error instanceof Error && error.message.includes("ENOENT");
}

function sortDirents(entries: StateDirent[]): StateDirent[] {
  return [...entries].sort((a, b) =>
    a.name < b.name ? -1 : a.name > b.name ? 1 : 0
  );
}

async function appendArchiveNode(
  node: StateTreeNode,
  entries: TarInputEntry[],
  readFileBytes: (path: string) => Promise<Uint8Array>
): Promise<void> {
  const relativePath = node.path.replace(/^\/+/, "");
  if (node.type === "directory") {
    entries.push({ path: relativePath || ".", type: "directory" });
    for (const child of node.children ?? []) {
      await appendArchiveNode(child, entries, readFileBytes);
    }
    return;
  }
  if (node.type === "file") {
    entries.push({
      path: relativePath,
      type: "file",
      bytes: await readFileBytes(node.path)
    });
  }
}