Packages
@bunli/store
Typed file-backed persistence for Bunli CLI apps
Installation
bun add @bunli/storeOverview
@bunli/store is a typed file-backed persistence layer for CLI applications. It provides:
- Atomic writes - Uses temp file + rename pattern for safe writes
- Type coercion - Automatically converts string values to numbers/booleans
- Field validation - Custom validator functions per field
- Default values - Fields with sensible defaults
- XDG support - Works with standard XDG base directory paths
Basic Usage
import { createStore } from "@bunli/store";
import { configDir } from "@bunli/utils";
// Define a typed config store
const store = createStore({
dirPath: configDir("my-cli"),
name: "settings",
fields: {
theme: { type: "string", default: "dark" },
port: { type: "number", default: 3000 },
verbose: { type: "boolean", default: false },
},
});
// Read current config
const config = await store.read();
// { theme: 'dark', port: 3000, verbose: false }
// Update the entire config
await store.write({ theme: "light", port: 8080, verbose: true });
// Patch specific fields
await store.patch({ port: 9000 });
// Update using a function
await store.update((current) => ({
...current,
port: current.port + 1,
}));
// Reset to defaults (deletes the file)
await store.reset();Field Types
Scalar Fields
const store = createStore({
dirPath: "./data",
fields: {
name: { type: "string", default: "unnamed" },
count: { type: "number", default: 0 },
enabled: { type: "boolean", default: false },
},
});Array Fields
const store = createStore({
dirPath: "./data",
fields: {
tags: { type: "string", array: true, default: [] },
scores: { type: "number", array: true, default: [0, 0] },
},
});
await store.patch({ tags: ["a", "b", "c"] });
// TypeScript knows tags is string[]Field Validation
const store = createStore({
dirPath: "./data",
fields: {
port: {
type: "number",
default: 3000,
validate: (value) => {
if (value < 1 || value > 65535) {
throw new Error("Port must be between 1 and 65535");
}
},
},
email: {
type: "string",
validate: (value) => {
if (!value.includes("@")) {
throw new Error("Invalid email address");
}
},
},
},
});Field Descriptions
const store = createStore({
dirPath: "./data",
fields: {
theme: {
type: "string",
default: "dark",
description: "Color theme to use",
},
},
});Store Options
interface StoreOptions<F extends FieldsDef> {
/** Directory path for the store file */
dirPath: string;
/** Store file name (without .json). Default: 'config' */
name?: string;
/** Field definitions */
fields: F;
/** Remove unknown fields on read. Default: true */
pruneUnknown?: boolean;
}Store Instance
interface StoreInstance<TConfig> {
/** Read and validate config from disk */
read(): Promise<TConfig>;
/** Overwrite entire config */
write(config: NoInfer<TConfig>): Promise<void>;
/** Update via a transformation function */
update(updater: (current: TConfig) => NoInfer<TConfig>): Promise<void>;
/** Shallow merge partial config */
patch(partial: Partial<NoInfer<TConfig>>): Promise<void>;
/** Delete config file (resets to defaults) */
reset(): Promise<void>;
}Type Inference
createStore automatically infers the config type from field definitions:
const store = createStore({
dirPath: "./data",
fields: {
name: { type: "string", default: "unnamed" },
count: { type: "number", default: 0 },
},
});
// config is typed as { name: string; count: number }
const config = await store.read();
config.name; // string
config.count; // numberError Handling
All errors extend TaggedError from better-result:
import {
StoreReadError,
StoreWriteError,
StoreParseError,
StoreValidationError,
} from "@bunli/store";
try {
const config = await store.read();
} catch (error) {
if (error instanceof StoreReadError) {
console.error(`Failed to read: ${error.path}`, error.cause);
} else if (error instanceof StoreParseError) {
console.error(`Malformed JSON: ${error.path}`);
} else if (error instanceof StoreValidationError) {
console.error(`Validation failed for ${error.field}:`, error.message);
}
}Error Classes
| Class | Description |
|---|---|
StoreReadError | Failed to read the store file |
StoreWriteError | Failed to write the store file |
StoreParseError | JSON parse error in store file |
StoreValidationError | Field validation failed |
Using with XDG Directories
import { createStore } from "@bunli/store";
import { configDir, dataDir, stateDir } from "@bunli/utils";
// Store in config directory (for settings)
const settings = createStore({
dirPath: configDir("my-cli"),
name: "settings",
fields: { theme: { type: "string", default: "dark" } },
});
// Store in data directory (for application data)
const cache = createStore({
dirPath: dataDir("my-cli"),
name: "cache",
fields: { entries: { type: "string", array: true, default: [] } },
});
// Store in state directory (for transient state)
const session = createStore({
dirPath: stateDir("my-cli"),
name: "session",
fields: { lastRun: { type: "number" } },
});Atomic Writes
The store uses atomic writes for safety:
- Writes to a temp file (
.store-<uuid>.tmp) - Creates parent directories if needed
- Renames temp file to final location
This means:
- No partial writes on crash
- No read during write
- Safe on network filesystems
Best Practices
Use Defaults
Always provide defaults for optional fields:
fields: {
port: { type: 'number', default: 3000 },
timeout: { type: 'number', default: 5000 }
}Validate Early
fields: {
email: {
type: 'string',
validate: (v) => {
if (!v.includes('@')) throw new Error('Invalid email')
}
}
}Use patch for User Settings
// Good: Only update what user changed
await store.patch({ theme: "light" });
// Avoid: Overwrites everything
await store.write({ theme: "light", port: 3000, verbose: false });See Also
- XDG Utilities -
configDir,dataDir,stateDirhelpers - Configuration - Bunli config system