Crust logoCrust

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/store

Quick Example

src/config.ts
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

  1. Pick a path helper (configDir, dataDir, stateDir, cacheDir) to resolve a platform-standard directory.
  2. createStore() resolves the file path once (<dirPath>/<name>.json) and returns a typed store instance.
  3. read() loads persisted JSON, applies field defaults for missing keys, and returns the complete state.
  4. write() atomically persists the full state (temp file + rename — no partial writes on crash).
  5. update() reads the current state, applies an updater function, and persists the result.
  6. patch() shallow-merges a partial update into the current state and persists.
  7. 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:

HelperIntentExample Use Case
configDirUser preferences & configTheme, editor settings, API keys
dataDirImportant app dataLocal databases, downloaded resources
stateDirRuntime stateWindow positions, scroll offsets, undo
cacheDirRegenerable cached dataHTTP caches, compiled assets
src/stores.ts
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:

HelperLinux / macOSWindowsEnv 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

OptionTypeRequiredDescription
dirPathstringYesAbsolute directory path where the JSON file is stored.
namestringNoStore name used as filename (default "config"config.json).
fieldsFieldsDefYesField definitions defining the store's data shape, types, defaults, and optional validation.
pruneUnknownbooleanNoDrop 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). Set pruneUnknown: false to 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:

CodeWhenDetails
PATHInvalid dirPath, invalid name, unsupported platform{ path: string }
PARSEMalformed JSON in persisted file{ path: string }
IOFilesystem read, write, or delete failure{ path, operation }
VALIDATIONOne 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

FunctionDescription
createStoreCreate a typed async store backed by JSON
configDirResolve platform-standard config directory for an app
dataDirResolve platform-standard data directory for an app
stateDirResolve platform-standard state directory for an app
cacheDirResolve platform-standard cache directory for an app

Types

TypeDescription
CreateStoreOptionsOptions object for createStore().
StoreStore instance with read, write, update, patch, reset.
StoreUpdaterUpdater function type (current: T) => T.
FieldDefSingle field definition with type, optional default, and optional validate.
FieldsDefRecord of field names to FieldDef definitions.
InferStoreConfigInferred store state type from a FieldsDef definition.
StoreValidatorIssueIssue object with { message: string, path: string }.
ValidationErrorDetailsDetails for VALIDATION errors: { operation, issues }.
StoreErrorCodeUnion of error codes: "PATH" | "PARSE" | "IO" | "VALIDATION".
PlatformEnvInjectable platform environment for testing path helpers.
CrustStoreErrorTyped 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

On this page