Store
DX-first, typed persistence for CLI apps with config/data/state/cache separation.
@crustjs/store gives your CLI production-ready local persistence with near-zero setup. Provide a fields definition, pick a storage intent, and get a typed store with read/write/update/patch/reset — no manual type annotations needed.
Install
bun add @crustjs/storeQuick Example
import { createStore, configDir } from "@crustjs/store";
const store = createStore({
dirPath: configDir("my-cli"),
fields: {
theme: { type: "string", default: "light" },
fontSize: { type: "number", default: 14 },
verbose: { type: "boolean", default: false },
},
});
// Read state (returns defaults when no persisted file exists)
const state = await store.read();
// → { theme: "light", fontSize: 14, verbose: false }
// Write a full state object
await store.write({ theme: "dark", fontSize: 16, verbose: true });
// Update with a function
await store.update((current) => ({ ...current, verbose: false }));
// Patch only specific keys
await store.patch({ theme: "solarized" });
// Reset to defaults (removes persisted file)
await store.reset();No explicit generics needed — types are inferred from your fields definition.
How It Works
- Pick a path helper (
configDir,dataDir,stateDir,cacheDir) to resolve a platform-standard directory. createStore()resolves the file path once (<dirPath>/<name>.json) and returns a typed store instance.read()loads persisted JSON, applies field defaults for missing keys, and returns the complete state.write()atomically persists the full state (temp file + rename — no partial writes on crash).update()reads the current state, applies an updater function, and persists the result.patch()shallow-merges a partial update into the current state and persists.reset()deletes the persisted file, reverting to defaults-on-read behavior.
Storage Intent
@crustjs/store provides four path helpers for different storage intents. Use the one that matches what you're persisting:
| Helper | Intent | Example Use Case |
|---|---|---|
configDir | User preferences & config | Theme, editor settings, API keys |
dataDir | Important app data | Local databases, downloaded resources |
stateDir | Runtime state | Window positions, scroll offsets, undo |
cacheDir | Regenerable cached data | HTTP caches, compiled assets |
import { createStore, configDir, dataDir, stateDir, cacheDir } from "@crustjs/store";
// User preferences → ~/.config/my-cli/config.json
const config = createStore({
dirPath: configDir("my-cli"),
fields: {
theme: { type: "string", default: "light" },
verbose: { type: "boolean", default: false },
},
});
// App data → ~/.local/share/my-cli/config.json
const data = createStore({
dirPath: dataDir("my-cli"),
fields: {
bookmarks: { type: "string", array: true, default: [] },
},
});
// Runtime state → ~/.local/state/my-cli/config.json
const state = createStore({
dirPath: stateDir("my-cli"),
fields: {
lastOpened: { type: "string", default: "" },
scrollY: { type: "number", default: 0 },
},
});
// Cache → ~/.cache/my-cli/config.json
const cache = createStore({
dirPath: cacheDir("my-cli"),
fields: {
etag: { type: "string", default: "" },
payload: { type: "string", default: "" },
},
});Platform Paths
All four helpers follow XDG conventions on Linux and macOS, and Windows-native conventions on Windows:
| Helper | Linux / macOS | Windows | Env Override |
|---|---|---|---|
configDir | ~/.config/<app> | %APPDATA%\<app> | $XDG_CONFIG_HOME |
dataDir | ~/.local/share/<app> | %LOCALAPPDATA%\<app>\Data | $XDG_DATA_HOME |
stateDir | ~/.local/state/<app> | %LOCALAPPDATA%\<app>\State | $XDG_STATE_HOME |
cacheDir | ~/.cache/<app> | %LOCALAPPDATA%\<app>\Cache | $XDG_CACHE_HOME |
macOS uses XDG conventions (same as Linux) for a unified Unix mental model,
not native ~/Library/... paths.
The optional env parameter on each helper accepts a PlatformEnv object for deterministic testing without mutating process.env.
Multiple Stores
Use the name option to maintain separate JSON files under the same directory:
import { createStore, configDir } from "@crustjs/store";
const dir = configDir("my-cli");
// ~/.config/my-cli/config.json (default)
const settingsStore = createStore({
dirPath: dir,
fields: {
theme: { type: "string", default: "light" },
fontSize: { type: "number", default: 14 },
verbose: { type: "boolean", default: false },
},
});
// ~/.config/my-cli/auth.json
const authStore = createStore({
dirPath: dir,
name: "auth",
fields: {
token: { type: "string", default: "" },
},
});Each store is fully independent — reading, writing, updating, and resetting one does not affect the others.
name defaults to "config" (producing config.json). It must not contain path separators or the .json extension.
API
createStore(options)
Creates a typed async store backed by a local JSON file. The file path is resolved once at creation time from dirPath and optional name.
const store = createStore(options);Parameters
| Option | Type | Required | Description |
|---|---|---|---|
dirPath | string | Yes | Absolute directory path where the JSON file is stored. |
name | string | No | Store name used as filename (default "config" → config.json). |
fields | FieldsDef | Yes | Field definitions defining the store's data shape, types, defaults, and optional validation. |
pruneUnknown | boolean | No | Drop unknown persisted keys on read (default true). |
Returns: A Store<T> with read(), write(), update(), patch(), and reset() methods.
Throws: CrustStoreError with PATH code if dirPath or name is invalid.
store.read()
Reads the persisted state file. Missing keys are filled from field defaults.
const state = await store.read();Always returns the inferred store shape. Fields with defaults are guaranteed present; fields without defaults may be undefined. Does not write merged defaults back to disk.
When per-field validate functions are configured, each field is validated after defaults are applied. Invalid persisted config fails loudly.
Throws: CrustStoreError with PARSE code on malformed JSON, VALIDATION if the config fails validation, IO code on filesystem failure.
store.write(state)
Atomically persists a full state object. The entire previous state is replaced. Parent directories are created if missing.
await store.write({ theme: "dark", fontSize: 14, verbose: true });Throws: CrustStoreError with VALIDATION if field validators reject, IO on filesystem failure.
store.update(updater)
Reads the current effective state, applies the updater function, and atomically persists the result.
await store.update((current) => ({
...current,
theme: "dark",
}));When no persisted file exists, the updater receives field defaults as the current value.
Throws: CrustStoreError with VALIDATION if field validators reject, PARSE on malformed JSON, IO on filesystem failure.
store.patch(partial)
Applies a shallow partial update to the current state and persists. Only the provided keys are updated; everything else is preserved.
await store.patch({ theme: "solarized" });Throws: CrustStoreError with VALIDATION if field validators reject, PARSE on malformed JSON, IO on filesystem failure.
store.reset()
Removes the persisted state file. After reset, read() returns defaults.
await store.reset();Reset is idempotent — calling it when no file exists is a no-op.
Throws: CrustStoreError with IO code on filesystem failure.
Defaults & Merge
When a persisted file exists, read() fills missing keys from field defaults:
const store = createStore({
dirPath: configDir("my-cli"),
fields: {
theme: { type: "string", default: "light" },
fontSize: { type: "number", default: 14 },
verbose: { type: "boolean", default: false },
},
});
// Persisted file contains: { "theme": "dark", "verbose": true }
const state = await store.read();
// → { theme: "dark", fontSize: 14, verbose: true }Merge Rules
- Persisted values override defaults.
- Missing keys fall back to defaults.
- Unknown persisted keys are dropped by default (
pruneUnknown: true). SetpruneUnknown: falseto preserve them. - All values are deep-cloned to prevent shared-reference mutation from defaults.
- Falsy values (
null,0,"",false) in the persisted file are preserved — only truly missing keys trigger defaults.
Merged defaults exist only in memory. The persisted file remains unchanged
until you explicitly call write(), update(), or patch().
Validation
Add a validate function to individual field definitions to validate values during read, write, update, and patch operations. Per-field validators are throw-based — return void on success, throw on failure:
const store = createStore({
dirPath: configDir("my-cli"),
fields: {
port: {
type: "number",
default: 3000,
validate(value) {
if (value < 1 || value > 65535) {
throw new Error("port must be between 1 and 65535");
}
},
},
host: { type: "string", default: "localhost" },
},
});
// Throws CrustStoreError with VALIDATION code
await store.write({ port: 0, host: "localhost" });Schema-Based Validation
For schema-driven validation, use field() from @crustjs/validate to create a per-field validator from a Zod, Effect, or any Standard Schema-compatible schema:
import { field } from "@crustjs/validate/zod";
import { z } from "zod";
const store = createStore({
dirPath: configDir("my-cli"),
fields: {
theme: {
type: "string",
default: "light",
validate: field(z.enum(["light", "dark"])),
},
verbose: { type: "boolean", default: false },
},
});validate is called on read, write, update, and patch — never on reset. If a validator throws, the operation is aborted and a CrustStoreError with VALIDATION code is thrown with structured issues.
Store field validators are validation-only. If you use schema transforms/coercions in field() / fieldSync(), transformed outputs are not written back into store state.
For synchronous validation, use fieldSync() from the appropriate @crustjs/validate entrypoint.
Error Handling
All errors thrown by @crustjs/store are instances of CrustStoreError with a typed code property:
| Code | When | Details |
|---|---|---|
PATH | Invalid dirPath, invalid name, unsupported platform | { path: string } |
PARSE | Malformed JSON in persisted file | { path: string } |
IO | Filesystem read, write, or delete failure | { path, operation } |
VALIDATION | One or more field validators rejected the state | { operation, issues } |
Catching Errors
import { CrustStoreError } from "@crustjs/store";
try {
const state = await store.read();
} catch (err) {
if (err instanceof CrustStoreError) {
switch (err.code) {
case "PARSE":
console.error(`Corrupt file at ${err.details.path}`);
break;
case "IO":
console.error(`File ${err.details.operation} failed: ${err.message}`);
break;
}
}
}Type Narrowing
The .is() method narrows the error type so details is fully typed:
if (err instanceof CrustStoreError && err.is("IO")) {
// err.details is { path: string; operation: "read" | "write" | "delete" }
console.error(err.details.operation, err.details.path);
}Validation Error Details
VALIDATION errors include structured issues for programmatic handling:
if (err instanceof CrustStoreError && err.is("VALIDATION")) {
// err.details is { operation: string; issues: StoreValidatorIssue[] }
for (const issue of err.details.issues) {
console.error(`${issue.path}: ${issue.message}`);
}
}Cause Chaining
CrustStoreError preserves the original error as cause via the .withCause() method:
if (err instanceof CrustStoreError) {
console.error("Original error:", err.cause);
}@crustjs/store does not silently recover from malformed JSON. If the
persisted file contains invalid JSON, read() throws immediately — no
fallback to defaults.
Exports
Functions
| Function | Description |
|---|---|
createStore | Create a typed async store backed by JSON |
configDir | Resolve platform-standard config directory for an app |
dataDir | Resolve platform-standard data directory for an app |
stateDir | Resolve platform-standard state directory for an app |
cacheDir | Resolve platform-standard cache directory for an app |
Types
| Type | Description |
|---|---|
CreateStoreOptions | Options object for createStore(). |
Store | Store instance with read, write, update, patch, reset. |
StoreUpdater | Updater function type (current: T) => T. |
FieldDef | Single field definition with type, optional default, and optional validate. |
FieldsDef | Record of field names to FieldDef definitions. |
InferStoreConfig | Inferred store state type from a FieldsDef definition. |
StoreValidatorIssue | Issue object with { message: string, path: string }. |
ValidationErrorDetails | Details for VALIDATION errors: { operation, issues }. |
StoreErrorCode | Union of error codes: "PATH" | "PARSE" | "IO" | "VALIDATION". |
PlatformEnv | Injectable platform environment for testing path helpers. |
CrustStoreError | Typed error class with code, details, and cause. |
Design
@crustjs/store is a standalone package — it has no dependency on @crustjs/core and is not injected into the command context. It handles one thing: persisting typed state objects as JSON files with platform-aware path resolution and clear storage intent separation.
Not included (by design):
- Alternative formats (YAML, TOML, JSON5) — JSON only
- Cross-process locking — assumes single-process usage
- Sync API variants
- Encryption or keychain integration
- Remote/cloud synchronization