Root API

Writing

Writing

The Root handle exposes a tight set of write verbs. Each one is atomic at the destination — no half-written intermediate state — and goes through the same boundary checks as reads.

await fs.write("state.json", body);
await fs.create("seed.json", initial);   // throws if exists
await fs.writeJson("config.json", state);
await fs.append("logs/today.log", line);
await fs.copyIn("inbox/upload.bin", "/tmp/upload.bin");
await fs.move("notes/draft.md", "notes/published.md");
await fs.remove("logs/yesterday.log");
await fs.mkdir("snapshots/2026/05");

#What every write does

  1. Resolve the relative target against the canonical root and reject anything that escapes (outside-workspace).
  2. If mkdir: true, create missing parent directories with the parent fd pinned.
  3. Open the parent directory by fd. Subsequent rename/unlink uses the parent fd, not the path string, so a parent-directory symlink swap mid-call cannot divert the write.
  4. Write data to a sibling temp file in the same directory.
  5. Atomically rename the temp file over the destination.
  6. Stat the resulting fd and verify identity.

A failure at any point either leaves the destination at its previous contents or surfaces an FsSafeError — never a partially-written file at the destination path.

#Write verbs

#fs.write(rel, data, options?)

Overwrite or create. Always atomic.

await fs.write("state/last-run.json", JSON.stringify(run));
await fs.write("notes/today.txt", "hello\n", { encoding: "utf8" });

data accepts string | Buffer. options are { encoding?: BufferEncoding; mkdir?: boolean; mode?: number; overwrite?: boolean }. mode sets the file's POSIX mode; if omitted, falls back to the mode from RootDefaults and then to umask. overwrite defaults to true; set it to false for the same no-clobber behavior as create().

#fs.create(rel, data, options?)

Don't-clobber variant of write(). Throws already-exists if the target is there.

try {
  await fs.create("config/seed.json", initial);
} catch (err) {
  if (err instanceof FsSafeError && err.code !== "already-exists") throw err;
}

#fs.writeJson(rel, value, options?)

JSON.stringify(value, replacer, space) + atomic write. Adds a trailing newline by default.

await fs.writeJson("config.json", state, { space: 2 });
await fs.writeJson("compact.json", state, { trailingNewline: false });

Options:

type RootWriteJsonOptions = {
  encoding?: BufferEncoding;
  mkdir?: boolean;
  mode?: number;
  replacer?: (this: any, key: string, value: any) => any | (number | string)[];
  space?: number | string;
  trailingNewline?: boolean; // default true
};

createJson(rel, value, options?) is the don't-clobber variant.

#fs.append(rel, data, options?)

Open in append mode, write, close. Honors mkdir for the parent directory. Pass prependNewlineIfNeeded: true to insert a \n if the file does not already end in one.

await fs.append("logs/today.log", `[${ts}] ${line}\n`);
await fs.append("notes/scratch.md", "* new bullet", { prependNewlineIfNeeded: true });

For high-volume logging, consider openWritable and a long-lived append handle.

#fs.copyIn(rel, sourceAbsPath, options?)

Bring a file from outside the root into the root, atomically. The source path must be absolute. The library streams the source through the boundary, writes to a sibling temp, and renames over the destination.

await fs.copyIn("inbox/upload.bin", "/tmp/incoming.bin", {
  maxBytes: 64 * 1024 * 1024,
});

Options: { encoding?, mkdir?, maxBytes?, sourceHardlinks? }. Use sourceHardlinks: "reject" to refuse if the source itself is a hardlinked alias.

#fs.move(from, to, options?)

Rename one path inside the root to another. Defaults to no clobber:

await fs.move("incoming/foo.txt", "archive/foo.txt");
await fs.move("incoming/foo.txt", "archive/foo.txt", { overwrite: true });

Both from and to are bounded; .. in either is rejected.

#fs.remove(rel)

Unlink a file or rmdir an empty directory. Non-empty directories throw not-empty. For atomic directory replacement, use replaceDirectoryAtomic.

await fs.remove("logs/yesterday.log");
await fs.remove("snapshots/empty-dir"); // ok
await fs.remove("snapshots/full-dir");  // throws not-empty

#fs.mkdir(rel)

mkdir -p. Creates missing parents.

await fs.mkdir("snapshots/2026/05");

#fs.ensureRoot()

Treats "" / "." as the root itself. Useful when a generic helper computes a relative directory and might end up at the root.

const targetRel = path.relative(fs.rootReal, candidateAbs); // could be "" if candidateAbs === root
await fs.ensureRoot(); // accepts "" without throwing

#openWritable() for streaming

When write doesn't fit (very large outputs, slow producers), open a writable handle:

const opened = await fs.openWritable("logs/current.log", { writeMode: "append" });
try {
  for await (const chunk of source) {
    await opened.handle.appendFile(chunk);
  }
} finally {
  await opened.handle.close();
}

Options: { mkdir?, mode?, writeMode? }, where writeMode is "replace" (default), "append", or "update". replace truncates existing files; update keeps existing contents. Streaming writes go directly to the destination — there is no atomic-rename step. If you need both streaming and atomicity, write to a sibling temp yourself and rename when done; the atomic helpers can do this for you.

#Write defaults vs per-call options

Set mkdir: true once on root(); pass text encodings per call when needed:

const fs = await root("/srv/workspace", {
  mkdir: true,
});

await fs.write("notes/today.txt", "ascii", { encoding: "utf8" });
await fs.write("data/blob.bin", buffer);     // mkdir true, no encoding because data is Buffer
await fs.write("data/blob.bin", buffer, { mkdir: false }); // override

#Errors you'll catch

CodeWhen
outside-workspaceTarget resolves outside the root.
already-existscreate() / createJson() / move({ overwrite: false }) hit an existing target.
not-foundParent does not exist and mkdir is false.
not-emptyremove() on a non-empty directory.
not-removableremove() could not unlink/rmdir (typically permissions or device busy).
path-mismatchPost-write fd identity check did not match. Almost always a parallel writer.
too-largecopyIn() source exceeded maxBytes.
symlinkA path component is a symlink and policy is reject.
hardlinksourceHardlinks: "reject" saw nlink > 1.

Full list in Errors.

#Common patterns

#Replace if changed

const next = JSON.stringify(state);
const prev = await fs.readText("state.json").catch(() => "");
if (prev !== next) await fs.write("state.json", next);

#Stage many writes, then commit

const stagingDir = "snapshots/incoming";
await fs.mkdir(stagingDir);
for (const file of files) await fs.write(`${stagingDir}/${file.name}`, file.body);
await fs.move(stagingDir, "snapshots/2026-05-05", { overwrite: true });

For a true commit-or-rollback over a directory, use replaceDirectoryAtomic.

#Rotate logs

const today = `logs/${formatDate(new Date())}.log`;
try {
  await fs.create(today, "");
} catch (err) {
  if (!(err instanceof FsSafeError) || err.code !== "already-exists") throw err;
}
await fs.append(today, line);

#See also

  • Atomic writes — the lower-level replaceFileAtomic and friends.
  • JSON files — standalone JSON helpers without going through root().
  • Reading — companion read API.
  • Errors — every code, when it fires.