Atomic & temp

Atomic writes

Atomic writes

@openclaw/fs-safe/atomic re-exports the lower-level helpers that root()'s write methods are built on. Reach for them when you have an absolute path you trust and want sibling-temp + rename without setting up a Root, or when you need finer control over fsync, mode preservation, or pre-rename hooks.

import {
  replaceFileAtomic,
  replaceFileAtomicSync,
  replaceDirectoryAtomic,
  movePathWithCopyFallback,
} from "@openclaw/fs-safe/atomic";

#replaceFileAtomic / replaceFileAtomicSync

Write content to a sibling temp file in the destination directory, optionally fsync the temp file, optionally fsync the parent directory after rename, then atomically rename over the destination.

import { replaceFileAtomic } from "@openclaw/fs-safe/atomic";

await replaceFileAtomic({
  filePath: "/srv/workspace/state.json",
  content: JSON.stringify(state, null, 2),
  mode: 0o600,
  syncTempFile: true,
  syncParentDir: true,
});

#Options

type ReplaceFileAtomicOptions = {
  filePath: string;                 // destination
  content: string | Uint8Array;
  dirMode?: number;                 // mode for parent dirs created by the helper
  mode?: number;                    // explicit mode for the new file (e.g. 0o600)
  preserveExistingMode?: boolean;   // copy mode from existing destination, when present
  tempPrefix?: string;
  renameMaxRetries?: number;
  renameRetryBaseDelayMs?: number;
  copyFallbackOnPermissionError?: boolean;
  syncTempFile?: boolean;           // fsync(temp) before rename
  syncParentDir?: boolean;          // fsync(parent) after rename (POSIX only)
  beforeRename?: (params: { filePath: string; tempPath: string }) => Promise<void>;
  fileSystem?: ReplaceFileAtomicFileSystem; // injectable fs for tests
};

#beforeRename

Runs after the temp file is fully written and before the rename. Use it to take a backup snapshot, capture the about-to-be-replaced contents, or notify an observer:

await replaceFileAtomic({
  filePath: "/srv/workspace/config.toml",
  content: rendered,
  beforeRename: async ({ filePath }) => {
    await fs.copyFile(filePath, `${filePath}.bak`); // snapshot existing
  },
});

If beforeRename throws, the rename is skipped and the temp file is removed — the destination is unchanged.

#EPERM and copy fallback

On systems where rename fails with EPERM/EEXIST, pass copyFallbackOnPermissionError: true to fall back to copy + unlink. The fallback refuses symlink destinations before copying so it does not write through a replaced destination link.

#Sync variant

replaceFileAtomicSync accepts the same options shape, with the obvious removal of the async-only hooks. Use it inside synchronous boot paths or test setup code.

#replaceDirectoryAtomic

Atomically swap one directory's contents with another, using a temporary backup during the swap.

import { replaceDirectoryAtomic } from "@openclaw/fs-safe/atomic";

await replaceDirectoryAtomic({
  stagedDir: "/srv/workspace/staging/snapshot-2026-05-05",
  targetDir: "/srv/workspace/snapshot",
});

The helper renames targetDir to a generated backup path, renames stagedDir → targetDir, then removes the backup. If the second rename fails, it tries to restore the original target before rethrowing.

Use it when callers must see a whole staged tree at the target path. For single-file replacement, replaceFileAtomic is the right tool.

#movePathWithCopyFallback

Rename a path. If the rename fails with EXDEV (cross-device) or EPERM, fall back to copy + remove. Preserves atomicity at the destination by writing the copy through replaceFileAtomic (for files) or staged-rename (for directories).

import { movePathWithCopyFallback } from "@openclaw/fs-safe/atomic";

await movePathWithCopyFallback({
  source: "/srv/cache/blob.bin",
  destination: "/srv/persistent/blob.bin",
  overwrite: true,
});

Use it when source and destination might live on different filesystems (containers, tmpfs, separate volumes).

#Difference from root()

Root methodsatomic helpers
Take relative paths, bound to a rootDir.Take absolute paths, no boundary.
Throw FsSafeError with code.Throw FsSafeError or the underlying NodeJS.ErrnoException, depending on failure point.
Atomicity, mode, hooks, fsync are sane defaults.Caller controls all of the above.
mkdir, identity check, hardlink reject built in.No identity check, no hardlink reject — pair with path helpers if you need them.

Use Root when the path is caller-controlled. Use atomic when the path is fully under your control and you want explicit knobs.

#Test injection

Both replaceFileAtomic and replaceFileAtomicSync accept a fileSystem option that overrides the small set of fs calls they make. Pass a stub in unit tests to assert order, simulate EPERM, or capture the temp filename:

const ops: string[] = [];
await replaceFileAtomic({
  filePath: "/tmp/x",
  content: "hi",
  fileSystem: {
    promises: {
      ...realFs,
      writeFile: async (...args) => { ops.push("write"); return realFs.writeFile(...args); },
      rename: async (...args) => { ops.push("rename"); return realFs.rename(...args); },
    },
  },
});

#See also

  • root() — when you want method-style writes with the boundary baked in.
  • JSON files — JSON/text helpers built on sibling-temp replacement.
  • Temp workspaces — for staging-then-swap directory builds.
  • Errors — code union for failures.