Crust logoCrust

Store

Typed JSON persistence with config, data, state, and cache paths.

@crustjs/store creates typed JSON stores from field definitions. A store supports read, write, update, patch, and reset.

Install

npm install @crustjs/store

Quick example

const store = createStore({
	dirPath: configDir("my-cli"),
	name: "config",
	fields: {
		theme: { type: "string", default: "light" },
		verbose: { type: "boolean", default: false },
	},
});
const initial = await store.read(); // { theme: "light", verbose: false }
await store.write({ theme: "dark", verbose: false });
await store.patch({ verbose: true });
await store.reset();
console.log(initial);

Field types and defaults determine the returned state type. Mutations validate and return the state they persisted.

How it works

~/.config/my-cli/config.json

createStore() resolves <dirPath>/<name>.json once. Reads merge defaults in memory; writes, updates, and patches validate before replacing the file atomically.

reset() removes the file. The next read() returns defaults without recreating it.

Storage intent

configDir("my-cli");
dataDir("my-cli");
stateDir("my-cli");
cacheDir("my-cli");

Choose the helper that matches user configuration, durable app data, runtime state, or regenerable cache data.

HelperLinux and macOSWindowsEnvironment variable
configDir~/.config/<app>%APPDATA%\<app>XDG_CONFIG_HOME on Linux/macOS; APPDATA on Windows
dataDir~/.local/share/<app>%LOCALAPPDATA%\<app>\DataXDG_DATA_HOME on Linux/macOS; LOCALAPPDATA on Windows
stateDir~/.local/state/<app>%LOCALAPPDATA%\<app>\StateXDG_STATE_HOME on Linux/macOS; LOCALAPPDATA on Windows
cacheDir~/.cache/<app>%LOCALAPPDATA%\<app>\CacheXDG_CACHE_HOME on Linux/macOS; LOCALAPPDATA on Windows

macOS uses the same XDG paths as Linux. Each helper accepts a PlatformEnv as its second argument for deterministic tests.

Multiple stores

const settings = createStore({ dirPath, name: "config", fields });
const auth = createStore({ dirPath, name: "auth", fields: authFields });

Each name maps to an independent JSON file. A name cannot contain path separators or end in .json.

API

const store = createStore({ dirPath, name, fields });

dirPath must be absolute. Core fields use type, optional array: true, default, and a throw-based validate function; schema fields use a Standard Schema v1 schema.

Prop

Type

Secure secret files

const auth = createStore({
  dirPath: configDir("my-cli"),
  name: "auth",
  fields: { token: { type: "string" } },
  access: "private",
});

On Unix, access: "private" uses mode 0600 for the file and 0700 for a parent directory the store creates. Windows uses inherited ACLs, so Unix permission bits are not enforced there.

An explicit { file, directory } object sets custom Unix permission bits.

store.read()

const state = await store.read();

read() parses the file, coerces core number and boolean strings, merges missing defaults, and validates fields. Only the strings "true" and "1" coerce to boolean true; every other string coerces to false. It does not write merged defaults to disk.

store.write(state)

const persisted = await store.write({ theme: "dark", verbose: false });

write() validates a complete state, atomically replaces the file, and returns the persisted state. Parent directories are created when needed.

store.update(updater)

const persisted = await store.update((current) => ({ ...current, verbose: true }));

update() reads and validates current values, resolving defaults and schema output before calling the typed updater. Invalid or missing required state rejects without calling it; use write() to initialize or patch() to repair instead. The updater result is also validated before persistence. Mutations are not serialized, so await operations on the same file in sequence.

store.patch(partial)

await store.patch({ verbose: true });

Before patch():

{
  "theme": "dark",
  "verbose": false
}

After patch():

{
  "theme": "dark",
  "verbose": true
}

patch() shallow-merges only the supplied keys, then validates and persists the result.

store.reset()

await store.reset();

Before reset, config.json contains the persisted JSON above. After reset, config.json does not exist, and read() returns { theme: "light", verbose: false } from defaults.

Reset is idempotent.

Defaults and merge

{ "theme": "dark", "verbose": false, "oldKey": true }

Missing defined keys receive defaults. Persisted falsy values are kept, and unknown keys are dropped by the default pruneUnknown: true; use false to preserve them.

Defaults are merged in memory only. Array defaults are copied so callers do not mutate the definition.

Validation

const ports = createStore({
	dirPath: configDir("my-cli"),
	name: "ports",
	fields: {
		port: {
			type: "number",
			default: 3000,
			validate(value) {
				if (value < 1 || value > 65535) throw new Error("Port must be 1-65535");
			},
		},
	},
});
await ports.read();

Core field validators run on read and mutation and throw to reject a value. They may return { value } to transform within the declared field type.

A schema-backed field gives validation, transformation, defaults, and optionality to its Standard Schema. It cannot also declare a Crust default or validate callback.

Reads return successful schema output in memory, including coercion, transforms, and nested defaults, without writing to disk. Core validate callback transforms remain mutation-only. Mutations revalidate transformed output and reject read-unstable transforms so returned values agree with the next read.

Explicit undefined is rejected for defaulted core fields on mutations; core fields without defaults remain optional. Schema fields retain schema-owned defaults and optionality; required schemas reject undefined unless the schema supplies a value.

Mutations also reject values changed by JSON serialization, including NaN, Infinity, -0, sparse arrays, and objects with undefined properties. Cyclic values and other serialization failures reject with field-scoped VALIDATION errors before persistence.

Error handling

try {
  await store.read();
} catch (error) {
  if (error instanceof CrustStoreError && error.is("PARSE")) console.error(error.details.path);
}

CrustStoreError.code is PATH, PARSE, IO, VALIDATION, or DEFINITION. The .is() method narrows the corresponding details shape.

Malformed JSON and non-object JSON roots reject with PARSE; they do not fall back to defaults.

Exports

import { createStore, configDir, dataDir, stateDir, cacheDir } from "@crustjs/store";

The package also exports CrustStoreError and the store, field, path, access, inference, and validation types.

Prop

Type

Prop

Type

Design

{ "format": "JSON", "locking": false }

The package is standalone and does not inject values into a Crust Context. It does not provide other formats, locking, encryption, migrations, remote storage, or synchronous methods.

On this page