File store
fileStore is exported from @openclaw/fs-safe/store. It is a managed wrapper around root() for the common "store files under a directory at known modes, prune old ones, hand back absolute paths" pattern. Useful for caches, ingest staging, generated artifacts, anywhere the consumer wants object-style access plus stream and copy primitives.
import {
fileStore,
type FileStore,
type FileStoreOptions,
type FileStoreWriteOptions,
type FileStorePruneOptions,
} from "@openclaw/fs-safe/store";
#When to reach for it
- You want a single directory holding files written by your code, with consistent mode bits and atomic placement.
- You want a
FileStore.write(rel, data)/read(rel)/pruneExpired(...)interface. - You want to feed a stream into the store with a byte cap.
- You don't need the full
Rootsurface (move, list, mkdir, …); the store can hand you a realRootvia.root()when you do.
#Factory: fileStore(options)
const cache = fileStore({
rootDir: "/var/cache/app",
mode: 0o600, // file mode for writes (default 0o600)
dirMode: 0o700, // mode for parent directories created on demand (default 0o700)
maxBytes: 64 * 1024 * 1024, // optional: refuse writes/reads larger than this
private: true, // use secret-file atomic writes for private state
durable: true, // sync file and parent directory (default true)
});
FileStoreOptions option | Default | Purpose |
|---|---|---|
rootDir | Required | Store directory. |
private | false | Use the secret-file atomic path. |
mode / dirMode | 0o600 / 0o700 | File and parent-directory modes. |
maxBytes | Unset | Store read/write byte limit. |
durable | true | Sync the written file and its parent directory; see method support below. |
Store and per-call maxBytes values must be non-negative safe integers or positive Infinity. Zero is an active zero-byte cap; Infinity disables the cap. An omitted or explicitly undefined per-call value preserves the store-level limit. The same rule applies to buffer writes, streams, copies, async reads, and synchronous reads/writes.
Use private: true for credentials, auth profiles, tokens, and other private state. Private mode keeps the same FileStore shape but routes writes through the secret-file atomic path, refusing symlink parent components and re-asserting mode after rename.
Returns a FileStore:
type FileStore = {
readonly rootDir: string;
path(relativePath: string): string;
root(): Promise<Root>;
write(rel, data: string | Uint8Array, options?): Promise<string>;
writeStream(rel, stream: Readable, options?): Promise<string>;
copyIn(rel, sourcePath: string, options?): Promise<string>;
open(rel, options?): Promise<OpenResult>;
read(rel, options?): Promise<ReadResult>;
readBytes(rel, options?): Promise<Buffer>;
readText(rel, options?): Promise<string>;
readTextIfExists(rel, options?): Promise<string | null>;
readJson<T = unknown>(rel, options?): Promise<T>;
readJsonIfExists<T = unknown>(rel, options?): Promise<T | null>;
writeText(rel, data: string | Uint8Array, options?): Promise<string>;
writeJson(rel, data: unknown, options?): Promise<string>;
json<T = unknown>(rel, options?): JsonStore<T>;
remove(rel): Promise<void>;
exists(rel): Promise<boolean>;
pruneExpired(options: FileStorePruneOptions): Promise<void>;
};
path() returns the absolute path the store would use, after asserting it stays inside rootDir. Useful for logging or for handing to other libraries.
Every relativePath is a portable store key. The same lexical policy applies to every keyed async and sync method, including reads, exists, remove, path(), and json() construction, with either private: false or true. Keys must use their exact canonical spelling; the store never trims, normalizes, or converts one caller-supplied key onto another:
- Keys are nonempty NFC Unicode strings without surrounding whitespace or NUL.
- Segments are separated by a single forward slash. Empty segments, repeated or
- Every backslash is rejected on every platform, including a literal POSIX
- Windows drive-relative segments such as
C:nameorC:are rejected - On Windows, every other colon is rejected too, preventing a key from naming
- No segment may end in an ASCII dot or space.
trailing slashes, and complete . or .. segments are rejected, including ./b, a/./b, and a/../b.
a\b filename. POSIX and Windows absolute, rooted, UNC, and extended paths are rejected.
anywhere in a key, including a/C:name.
an NTFS alternate stream or directory-index alias. POSIX keeps accepting ordinary colon-bearing segments that are not drive-relative spellings.
Violations report invalid-path when key validation is reached. Ordinary nested keys, NFC Unicode such as café/日本語.txt, .hidden, a..b, and internal spaces such as internal space/a b.txt are accepted. On POSIX, colons elsewhere, such as the timestamp in logs/2026-08-02T10:30:00Z.log, remain lexically valid; Windows rejects that spelling as stream syntax.
Validation retains each method's operation order. Async reads, exists, and remove open the root first: if the root is missing, strict methods report not-found and readTextIfExists / readJsonIfExists return null, even for an invalid key. With an existing root, those same invalid keys report invalid-path. Private copyIn checks and reads its source before validating the destination key, and writeJson serializes its value before validating the key. Key rejection therefore does not imply that no filesystem access or serialization occurred.
root() returns a Root handle for the same directory when you need the full surface (move, list, mkdir). It's a fresh handle per call and is safe to call frequently.
#Writes
Writes use guarded sibling-temp publication: apply file and directory modes, then rename into place. By default, the file and parent directory are synced where supported by the platform and writer.
durable: false keeps the sibling-temp replace/rename behavior but skips the temp-file and parent-directory fsync calls. Use it only for reconstructible metadata where lower latency matters more than crash-durability. Per-call durable overrides the store option, which defaults to true; an omitted or undefined override preserves the store default. Modes, path confinement, and publication identity checks are unchanged.
Synchronous writes retain their original write-only descriptor through rename and publication checks, using exact bigint file identities. When Windows cannot report a pathname's identity, verification reopens the name only to compare its descriptor with the retained writer; it never reads file contents. A substituted file is rejected even if its bytes match, and a post-publication failure leaves the published entry intact for caller-owned recovery. Ordinary write-only and mode-000 outputs do not require a readable descriptor when pathname metadata is available.
If a synchronous write operation and its final temp-descriptor close both fail, the store reports them in operation-then-close order in an AggregateError. This ordering and the original JavaScript thrown value are preserved even when that value is undefined or otherwise falsy. An unsuccessful best-effort temp unlink remains registered for identity-checked process-exit cleanup; it does not prevent the close attempt or replace either reportable failure.
If an opaque pathname cannot be reopened because of an ACL denial or sharing restriction, the synchronous writer intentionally rejects with path-mismatch: its exact publication identity cannot be verified. There is no equal-content fallback. The published entry remains present, so callers must inspect or recover that outcome instead of assuming the write did not occur.
Synchronous directory creation retains exact bigint receipts for the store root and every parent component. On POSIX, an existing or newly created directory whose complete requested mode differs is reopened without following the final name, checked against its receipt and parent chain, and finalized through that descriptor. A concurrent root or parent replacement is rejected without applying the mode to the replacement. Directories already at the requested mode skip the descriptor and mode operation. Windows retains its bounded mkdir mode request and identity checks without relying on directory descriptors or a pathname chmod, because Node does not enforce POSIX directory modes there.
Node does not expose a portable, fchmod-capable search-only directory descriptor on Linux. If a mismatched existing directory, or one created under an owner-read-removing umask, cannot be opened for reading, the synchronous store therefore fails closed with permission-unverified; it never falls back to pathname chmod. On supported macOS x64/arm64 hosts it also tries an O_SEARCH descriptor, so owner-searchable directories can still be repaired. Directories with neither usable read nor search access remain fail-closed.
| Method | Durability support |
|---|---|
write, writeText, writeJson (async and sync) | Per-call option overrides store default. |
writeStream, copyIn (either private mode) | Per-call option overrides store default. |
JSON write, update, updateOr | JSON handle option overrides file-store default. |
#write(rel, data, options?)
const path = await cache.write("entries/2026/05/05.json", JSON.stringify(entry));
Buffer or string. Returns the final absolute path. Throws too-large if data.byteLength exceeds maxBytes.
#writeText(rel, data, options?) / writeJson(rel, data, options?)
Convenience wrappers over write. writeJson pretty-prints with a trailing newline by default and accepts { trailingNewline: false } when the exact bytes matter.
#json<T>(rel, options?)
Returns a typed single-file JSON state helper for a file under this store. It inherits the store's root, mode, max-size, durability, and private-write policy, then adds readOr, readRequired, update, updateOr, and optional sidecar locking:
const state = cache.json<State>("state/settings.json", { lock: true });
await state.updateOr(defaultState, (current) => ({ ...current, enabled: true }));
Use this when one JSON file owns one piece of state. jsonStore({ filePath }) is the absolute-path convenience wrapper for the same primitive.
Pass { durable: false } or { durable: true } to json() to override the parent store's durability for all mutations of that JSON handle.
#writeStream(rel, stream, options?)
import { Readable } from "node:stream";
const path = await cache.writeStream("downloads/blob.bin", Readable.from(remoteFetch));
Streams into a sibling temp with a running byte budget. Aborts the source stream with too-large if maxBytes is exceeded mid-stream — partial writes are cleaned up.
Streams honor durable in both private modes. Non-private streams stage their input and forward the resolved durability option to Root copyIn for publication.
#copyIn(rel, sourcePath, options?)
const path = await cache.copyIn("ingest/upload.bin", "/tmp/upload.bin");
One-shot ingest from an absolute source path. Source is checked for symlink/non-regular before copy. Same mode rules as write.
copyIn honors per-call and store-level durable values in both private modes, with the same precedence as write.
#FileStoreWriteOptions
Per-call overrides for the store-level defaults:
Writes capture byte limits, modes, and durability before asynchronous work or stream consumption. JSON writes capture those fields and the trailing-newline setting before serialization. Later mutation cannot change those captured values. Accessors run on the original options object. Ordinary writes retain content conversion and byte-limit validation before reading modes and durability. The legacy non-private stream tempPrefix accessor still runs after staging; it does not control the publication policy.
type FileStoreWriteOptions = {
durable?: boolean; // store default, otherwise true
dirMode?: number;
mode?: number;
maxBytes?: number;
tempPrefix?: string; // override the default "." + basename
};
FileStoreWriteOptions option | Default |
|---|---|
durable | Store option, otherwise true. |
dirMode / mode | Store directory/file modes. |
maxBytes | Store byte limit. |
tempPrefix | Writer-specific temporary prefix. |
The same durability precedence applies to fileStoreSync().write, writeText, and writeJson. The synchronous store has no writeStream or copyIn methods.
#Reads
open, read, readBytes, readText, and readJson delegate to a fresh Root with hardlinks: "reject" and the store's maxBytes. Same return shapes as Root. Async and sync reads both report not-file when the key names a directory; stable hardlinks and symlinks retain hardlink and symlink. The IfExists methods return their nullish result only for not-found. Operational filesystem read failures use read-failed with the Node error retained in cause.
#remove(rel) / exists(rel)
Forward to the underlying Root. remove unlinks files and rmdirs empty directories; non-empty dirs throw not-empty.
#pruneExpired(options)
Walk the store and delete files older than options.ttlMs:
await cache.pruneExpired({
ttlMs: 7 * 24 * 60 * 60 * 1000, // 7 days
recursive: true,
pruneEmptyDirs: true,
});
Options:
type FileStorePruneOptions = {
ttlMs: number;
recursive?: boolean; // default false (top-level only)
maxDepth?: number; // bound recursion explicitly
pruneEmptyDirs?: boolean; // also remove dirs that became empty (only with recursive/maxDepth)
};
Symlinks are skipped. The walk is best-effort — failures on individual entries don't abort the whole prune. Compares against mtimeMs.
Pruning rechecks that a selected entry is still a regular file and still expired immediately before guarded removal. Fresh replacements and in-place timestamp refreshes are preserved; replacements that are themselves expired remain eligible. This does not require read permission. The existing best-effort external-process race window after dispatch still applies.
Empty-directory pruning likewise rechecks that the selected entry is still a directory immediately before guarded removal. File and symlink replacements are preserved, and a directory that becomes nonempty is left in place.
#Difference from Root
FileStore | Root |
|---|---|
| Object-style with mode+dirMode baked in. | Method-style boundary; mode is per-call or per-default. |
writeStream with built-in byte budget. | Manual via openWritable(). |
writeText / writeJson return the final absolute path. | Root.write / writeJson return void. |
copyIn returns the final absolute path. | Root.copyIn returns void. |
pruneExpired walks by mtime. | No prune helper. |
Reads delegate via Root internally. | The boundary itself. |
If you need richer ops (move, list, append, mkdir), call store.root() to get a Root and use that.
FileStore owns portable key identity; Root owns filesystem confinement. Existing-object Root lookups intentionally retain broader confined path compatibility, including in-root absolute paths and parent-segment spellings, and literal backslashes on POSIX. FileStore rejects those spellings even when Root could resolve them safely inside the directory. This key policy does not change Root's containment checks or the absolute-path jsonStore({ filePath }) contract.
#Common patterns
#Cache with TTL prune
const cache = fileStore({ rootDir: "/var/cache/app", maxBytes: 16 * 1024 * 1024 });
await cache.writeStream(`${id}.bin`, fetchStream(id));
// Background prune every hour
setInterval(() => cache.pruneExpired({ ttlMs: 24 * 60 * 60 * 1000 }), 60 * 60 * 1000);
#Ingest pipeline
const ingest = fileStore({ rootDir: "/srv/ingest", mode: 0o644 });
for (const upload of uploads) {
const dest = await ingest.copyIn(`raw/${upload.id}`, upload.tempPath, {
maxBytes: 200 * 1024 * 1024,
});
await enqueueProcess(dest);
}
#Drop down to Root for moves
const root = await store.root();
await root.move(`pending/${id}`, `done/${id}`);
#See also
root()— the boundaryFileStoreis built on; reach for it when you need move/list/append.- JSON store — the JSON-state-file equivalent of this surface.
- Atomic writes — lower-level sibling-temp publication helpers.
- Temp workspaces — private scratch directories backed by
FileStore.