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). |
access | "default" | "private" | { file?: number; directory?: number } | No | Persistence visibility. Use "private" for owner-only secret stores, or pass explicit Unix permission bits. |
Returns: A Store<T> with read(), write(), update(), patch(), and reset() methods.
Throws: CrustStoreError with PATH code if dirPath or name is invalid.
Securing secrets
Config/state stores that hold tokens or API keys should not be world-readable. Pass access: "private" so the file is owner-only on Unix:
const auth = createStore({
dirPath: configDir("my-cli"),
name: "auth",
fields: { token: { type: "string" } },
access: "private",
});
await auth.write({ token: "secret" }); // auth.json is 0600 even under a permissive umaskaccess: "private" maps to 0600 for the file (rw-------) and 0700 for the parent directory (rwx------, only when created). The file mode is enforced on the temp file before the atomic rename, so the persisted file is never momentarily group/other-readable.
The built-in string presets are intentionally small:
| Access | File | Directory | Use case |
|---|---|---|---|
omitted / "default" | Platform default, usually 0644 after a common umask | Platform default, usually 0755 after a common umask | Non-secret stores where the user's environment should decide permissions. |
"private" | 0600 | 0700 | Secret-bearing stores such as tokens, API keys, and local auth state. |
Advanced callers can provide explicit permission bits without adding more named presets:
const store = createStore({
dirPath: configDir("my-cli"),
name: "auth",
fields: { token: { type: "string" } },
// Owner-only secret store (same as access: "private")
access: { file: 0o600, directory: 0o700 },
// Group-readable store; group ownership is managed outside @crustjs/store
// access: { file: 0o640, directory: 0o750 },
// World-readable non-secret store
// access: { file: 0o644, directory: 0o755 },
});Platform behavior: access permission bits are enforced on macOS and Linux (and other Unix systems), where POSIX permission bits are checked by the OS. On Windows they are not enforced — Windows uses ACLs, not Unix bits, so access: "private" does not make a file owner-only there. The store does not throw or warn in this case. On Windows, confidentiality relies on the ACL inherited from the parent directory; the per-user profile location resolved by configDir / stateDir (under %APPDATA% / %LOCALAPPDATA%) is already restricted to the user. If you need strong secret confidentiality on Windows, use the OS credential store (Windows Credential Manager / DPAPI) rather than file permissions.
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-driven validation
The built-in field() factory builds a FieldDef from any
Standard Schema v1 object. Crust stores raw
values; the schema validates and transforms them on read/write.
import { configDir, createStore, field } from "@crustjs/store";
import { z } from "zod";
const store = createStore({
dirPath: configDir("my-cli"),
fields: {
theme: field(z.enum(["light", "dark"]).default("light")),
verbose: { type: "boolean", default: false },
},
});Effect users wrap their schema once with Schema.standardSchemaV1(...)
before passing it to field():
import * as Schema from "effect/Schema";
import { configDir, createStore, field } from "@crustjs/store";
const store = createStore({
dirPath: configDir("my-cli"),
fields: {
theme: field(Schema.standardSchemaV1(Schema.Literal("light", "dark"))),
},
});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.
Schema transforms in fields built via field() are applied on write,
update, and patch — never on read. The transformed output replaces
the input before persistence and is then re-validated once: a transform
whose output the schema would itself reject (e.g.
z.string().transform(Number) — string in, number out) is rejected at
write time with CrustStoreError("VALIDATION") and an issue message
tagged read-unstable transform.
On read, the schema still validates the persisted value but its
transform output is discarded — the returned value matches what is on
disk. Existing on-disk values for schema-using stores survive unchanged
across upgrades; the first subsequent write / update / patch
canonicalizes them through the transform.
Missing persisted values flow through the schema as undefined, so
field(z.string().default("x")) materializes "x" on read,
field(z.string().optional()) returns undefined, and
field(z.string()) fails validation with a VALIDATION error.
Hand-rolled validate: (v) => { ... } callbacks that return void are
unaffected — they remain validation-only (throw to reject, return void
to accept) and cannot transform the persisted value.
Schema-derived defaults and TypeScript
Standard Schema v1 has no spec-portable type-level access to schema
defaults. Crust does not inspect schemas to populate runtime metadata;
defaults are materialized on read by validating undefined through the
schema. As a result:
field(z.string().default("x"))returns"x"on read when the field is missing from disk, but the inferred config type isstring | undefined(NOT narrowed).field(z.string(), { default: "x" })keeps"x"as the Crust-level default AND narrows the inferred config type tostring.
Prefer the explicit form when the field is required to always have a value at use sites.
field() throws on invalid input
field() throws CrustStoreError("DEFINITION") when handed a value that
is not a Standard Schema v1 object. type is optional for schema-backed
fields; pass it through opts only as legacy metadata for tooling.
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 } |
DEFINITION | field() received an invalid (non-Standard-Schema) input | { vendor? } |
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 |
field | Build a FieldDef from any Standard Schema v1 object |
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. |
FieldOptions | Optional Crust metadata accepted by the field() factory. |
StoreValidatorIssue | Issue object with { message: string, path: string }. |
ValidationErrorDetails | Details for VALIDATION errors: { operation, issues }. |
DefinitionErrorDetails | Details for DEFINITION errors: { vendor? }. |
StoreErrorCode | Union of error codes: "PATH" | "PARSE" | "IO" | "VALIDATION" | "DEFINITION". |
ValueType | Supported field type literals: "string" | "number" | "boolean". |
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