Private state store
privateStateStore({ rootDir }) returns a small handle for reading and writing JSON or text state inside a trusted root directory. Every write atomically creates the parent directory tree at mode 0o700 and the file at mode 0o600.
import { privateStateStore } from "@openclaw/fs-safe/store";
const store = privateStateStore({ rootDir: "/var/lib/app" });
await store.writeJson("state.json", state);
const loaded = await store.readJson<State>("state.json");
#When to reach for it
- You have a single trusted directory holding small JSON or text state.
- You want every write to land at mode
0o600in dirs at0o700without thinking about it. - You don't need
move,remove,list,copyIn, or streaming — only read/write.
For richer file-store needs (remove, exists, open, copy-in, pruning, streams), use fileStore. For general root operations, use root(). For one-off credential reads, use the secret-file helpers.
#API
type PrivateStateStoreOptions = {
rootDir: string;
};
type PrivateStateStore = {
rootDir: string;
path(relativePath: string): string;
readText(relativePath: string, options?: { maxBytes?: number }): Promise<string | null>;
readJson<T = unknown>(relativePath: string, options?: { maxBytes?: number }): Promise<T | null>;
writeText(relativePath: string, content: string | Uint8Array): Promise<void>;
writeJson(relativePath: string, value: unknown, options?: { trailingNewline?: boolean }): Promise<void>;
};
function privateStateStore(options: PrivateStateStoreOptions): PrivateStateStore;
store.path(rel) returns the absolute path the store would use, useful for logging or for handing to other libraries that take absolute paths.
readText and readJson return null when the file is missing — lenient by design. Callers that want strict failure on missing should check the result and throw.
#Advanced standalone helpers
The standalone function form lives in @openclaw/fs-safe/advanced. Use it when you don't want to pin a single root:
import {
writePrivateTextAtomic, // async
writePrivateTextAtomicSync, // sync
writePrivateJsonAtomic, // async
writePrivateJsonAtomicSync, // sync
readPrivateText, // async
readPrivateTextSync, // sync
readPrivateJson, // async
readPrivateJsonSync, // sync
} from "@openclaw/fs-safe/advanced";
Each standalone takes { rootDir, filePath, ... } directly:
await writePrivateJsonAtomic({
rootDir: "/var/lib/app",
filePath: "/var/lib/app/state.json",
value: state,
trailingNewline: true,
});
filePath is an absolute path. The helper asserts it stays inside rootDir and refuses anything that would escape.
#Examples
#Read-modify-write
const store = privateStateStore({ rootDir: "/var/lib/app" });
const state = (await store.readJson<State>("state.json")) ?? initialState();
state.count += 1;
await store.writeJson("state.json", state, { trailingNewline: true });
#Sync at boot
import { readPrivateJsonSync } from "@openclaw/fs-safe/advanced";
const config =
readPrivateJsonSync({ rootDir: "/etc/app", filePath: "/etc/app/config.json" }) ??
defaultConfig();
applyConfig(config);
#Bounded reads
const config = await store.readJson<Config>("config.json", { maxBytes: 64 * 1024 });
if (!config) throw new Error("config missing");
maxBytes is forwarded into the read; oversized files throw too-large from the underlying Root.
#Behavior notes
- Mode bits. Writes always end at file mode
0o600and create parent directories at0o700. The store does not narrow modes on existing wider parents — it sets the mode at creation only. Audit existing trees yourself. - Hardlinks. Reads refuse files with
nlink > 1(defense-in-depth, since the file might alias an out-of-tree inode). - Symlinks. Refused everywhere along the resolved path.
- Sync writes. The standalone
*Syncwriters are appropriate for boot paths or test fixtures. They use the same atomic-rename mechanism as the async variant.
#Difference from Root
privateStateStore | Root |
|---|---|
Reads return null on miss. | Reads throw with code not-found. |
| Four verbs (text in/out, JSON in/out). | Full surface (move, remove, list, …). |
| Writes always set 0600 on the file and 0700 on the parents. | Writes use the umask unless you override. |
| No streaming. | open() returns a FileHandle. |
If you find yourself asking "does the store have an X?" — reach for fileStore() or root().
#See also
root()— full method-style boundary.- Secret files — standalone read/write of mode-0600 credential files.
- JSON files — strict/lenient JSON helpers without per-store fanout.
- Atomic writes — what these writes use under the hood.