Stores

JSON store

JSON store

jsonStore is exported from @openclaw/fs-safe/store. It is the absolute-path convenience wrapper for fileStore(...).json(...): a small read-modify-write handle around a single JSON file. It bakes in atomic writes, explicit fallback reads, and optional cross-process locking via acquireFileLock.

import { jsonStore } from "@openclaw/fs-safe/store";

const settings = jsonStore<{ theme: "light" | "dark"; volume: number }>({
  filePath: "/var/lib/app/settings.json",
});

const current = await settings.readOr({ theme: "dark", volume: 0.7 });
await settings.write({ ...current, volume: 1 });
await settings.updateOr({ theme: "dark", volume: 0.7 }, (prev) => ({ ...prev, theme: "light" }));

If you already have a store/root context, prefer binding the JSON file from that store:

import { fileStore } from "@openclaw/fs-safe/store";

const files = fileStore({ rootDir: "/var/lib/app", private: true });
const settings = files.json<Settings>("settings.json", { lock: true });

#When to reach for it

  • You have a single JSON state file and want read / readOr / readRequired / write / update semantics.
  • You want every write atomic at file mode 0o600 and parents at 0o700 by default.
  • You want optional cross-process locking with one boolean.

For ad-hoc read/write of multiple JSON files, use the standalone helpers in json. For object-style storage of many files at known modes, use fileStore and bind JSON files with store.json(rel).

#Factory: jsonStore<T>(options)

type JsonStoreOptions<T> = {
  filePath: string;
  dirMode?: number;                                // default 0o700
  mode?: number;                                   // default 0o600
  trailingNewline?: boolean;                       // default true
  lock?: boolean | JsonStoreLockOptions;           // false / undefined = no lock
};

type JsonStoreLockOptions = {
  staleMs?: number;     // default 30_000
  timeoutMs?: number;   // default 30_000
  retry?: FileLockRetryOptions;
  staleRecovery?: "fail-closed" | "remove-if-unchanged";
  managerKey?: string;  // default `fs-safe.json-store:<filePath>`
};

type JsonStore<T> = {
  readonly filePath: string;
  read(): Promise<T | undefined>;
  readOr(fallback: T): Promise<T>;
  readRequired(): Promise<T>;
  write(value: T): Promise<void>;
  update(run: (current: T | undefined) => T | Promise<T>): Promise<T>;
  updateOr(fallback: T, run: (current: T) => T | Promise<T>): Promise<T>;
};

jsonStore({ filePath }) resolves rootDir = dirname(filePath) and calls fileStore({ rootDir, private: true }).json(basename(filePath), options).

The store does not validate the parsed value against T at runtime — the cast is unchecked. Wrap with a schema (zod/valibot) if the file might be hand-edited or written by another process you don't control.

#read()

Returns the parsed contents, or undefined if the file does not exist. Invalid JSON throws (via readJsonIfExists).

const state = await store.read();

#readOr(fallback)

Returns the parsed contents or the per-call fallback. Object fallbacks are cloned so callers can safely mutate the returned value:

const state = await store.readOr(defaultState);

#readRequired()

Strict disk read. Throws when the file is missing or invalid:

const state = await store.readRequired();

#write(value)

Atomic JSON write at mode (default 0o600), creating parent dirs at dirMode (default 0o700) if needed. When lock: true is set, takes the sidecar lock for the duration of the write.

await store.write({ ...state, lastSeen: Date.now() });

#update(run)

Read, transform, write — under the lock if locking is enabled. Returns the new value:

const next = await store.update((prev) => ({ count: (prev?.count ?? 0) + 1 }));

run is async-friendly. The whole read → run → write sequence runs inside one withLock call, so concurrent updaters from different processes serialize cleanly.

Use update(run) when missing state is part of your model. Use updateOr(fallback, run) when the missing-file case should start from a concrete value and you want to merge into defaults:

const next = await store.updateOr({ count: 0 }, (prev) => ({ count: prev.count + 1 }));

#Locking

Set lock: true for default behavior, or pass an options object to tune:

const counter = jsonStore<{ count: number }>({
  filePath: "/var/lib/app/counter.json",
  lock: {
    staleMs: 60_000,
    timeoutMs: 10_000,
    staleRecovery: "fail-closed",
    retry: { retries: 30, minTimeout: 100, maxTimeout: 5_000, randomize: true },
  },
});

When lock is falsy, read / write / update are unlocked. The update shape is still useful — it gives you a single function for the read-modify-write pattern — but it offers no concurrency guarantees if other processes also write to the file.

Process-wide lock defaults from configureFsSafeLocks() apply only after locking is explicitly enabled. They do not make JSON stores lock by default.

JSON store locks do not expose shouldRemoveStaleLock, so staleRecovery: "remove-if-unchanged" cannot remove a stale sidecar by itself. Use the lower-level file lock API when your application needs custom owner-liveness checks and caller-approved stale-lock removal.

The default managerKey namespaces the in-process FileLockManager per absolute file path, so two jsonStore calls on the same file share lock state automatically.

#Common patterns

#Per-feature settings file

type Settings = { theme: "light" | "dark"; muted: boolean };

const settings = jsonStore<Settings>({
  filePath: path.join(homedir(), ".myapp/settings.json"),
});

// Read on boot
applySettings(await settings.readOr({ theme: "dark", muted: false }));

// Toggle on UI action
await settings.update((prev) => {
  const current = prev ?? { theme: "dark", muted: false };
  return { ...current, muted: !current.muted };
});

#Cross-process counter

const counter = jsonStore<{ count: number }>({
  filePath: "/var/lib/app/counter.json",
  lock: true,
});

const { count } = await counter.updateOr({ count: 0 }, (prev) => ({ count: prev.count + 1 }));
console.log("now at", count);

#Migration on boot

const config = jsonStore<Config>({ filePath });
const current = await config.readOr(defaultConfig);
if (current.version !== CURRENT_VERSION) {
  await config.write(migrate(current));
}

#Difference from raw writeJson / readJsonIfExists

jsonStoreRaw helpers
Read-modify-write in one call (update).Compose readJsonIfExists + writeJson yourself.
Optional cross-process lock with one flag.Manage withFileLock yourself.
Explicit readOr / updateOr fallbacks.Caller handles null and clones.
Mode/dirMode locked per store.Per-call.

jsonStore is the right shape when one file owns one piece of state and many call sites read or update it. For one-off writes, the raw helpers are leaner.

#See also

  • JSON files — the standalone helpers jsonStore is built on.
  • File lock — the cross-process lock used when lock: true.
  • File store — the multi-file equivalent of this surface.