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).
access"default" | "private" | { file?: number; directory?: number }NoPersistence 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 umask

access: "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:

AccessFileDirectoryUse case
omitted / "default"Platform default, usually 0644 after a common umaskPlatform default, usually 0755 after a common umaskNon-secret stores where the user's environment should decide permissions.
"private"06000700Secret-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). 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-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 is string | undefined (NOT narrowed).
  • field(z.string(), { default: "x" }) keeps "x" as the Crust-level default AND narrows the inferred config type to string.

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:

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 }
DEFINITIONfield() 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

FunctionDescription
createStoreCreate a typed async store backed by JSON
fieldBuild a FieldDef from any Standard Schema v1 object
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.
FieldOptionsOptional Crust metadata accepted by the field() factory.
StoreValidatorIssueIssue object with { message: string, path: string }.
ValidationErrorDetailsDetails for VALIDATION errors: { operation, issues }.
DefinitionErrorDetailsDetails for DEFINITION errors: { vendor? }.
StoreErrorCodeUnion of error codes: "PATH" | "PARSE" | "IO" | "VALIDATION" | "DEFINITION".
ValueTypeSupported field type literals: "string" | "number" | "boolean".
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