@bnk/kv-store
is a highly modular, zero-dependency, TypeScript-based key-value store for Bun. At its core, it’s an in-memory store that can optionally sync and persist to different backends, such as SQLite or files. It supports:
- In-Memory caching of data for performance
- Optional persistence to data stores (SQLite, file, etc.)
- Validation capabilities (Zod, custom functions, etc.)
- Hooks to trigger side effects on data changes, backups, or deletes
- Versioning to keep track of changes over time
- Backups for reliability
- Easy Testing with small, pluggable modules
This library is especially handy for lightweight or rapid data storage needs in Bun, such as storing configuration, caching requests, or building small local apps without a heavy database requirement.
bun add @bnk/kv-store
Once installed, you can import it anywhere in your Bun + TypeScript project:
import { KeyValueStore } from '@bnk/kv-store';
The simplest way to use the library is in-memory only, meaning no data is persisted to disk or a database. This is great for temporary caching or ephemeral data during app runtime.
import { KeyValueStore } from '@bnk/kv-store';
const store = new KeyValueStore({});
// Setting a key
store.set('greeting', 'Hello, world!');
// Getting a key
const greeting = store.get<string>('greeting');
console.log(greeting); // "Hello, world!"
// Deleting a key
store.delete('greeting');
-
Key Strings Only
All keys must be strings. -
Value Serialization
Values are stored in memory as JavaScript objects, but for potential persistence, they must be JSON-serializable. If you try to set circular references or other non-serializable data, an error will be thrown. -
Optionally typed
You can pass a type parameter (e.g.store.get<MyType>("myKey")
) for TypeScript convenience. However, TypeScript won't enforce it unless you use a validator (see Validation below).
To persist your data in an SQLite database:
import { KeyValueStore, SqliteAdapter } from '@bnk/kv-store';
async function runExample() {
const adapter = new SqliteAdapter({ path: 'my-store.db' });
const store = new KeyValueStore({
adapter,
syncIntervalMs: 5000 // automatically sync every 5 seconds
});
// Initialize store (creates table if needed, then loads existing data)
await store.init();
// Set a key in memory
store.set('user', { name: 'Alice', age: 29 });
// Force immediate sync to SQLite
await store.sync();
// Confirm in memory
console.log(store.get('user')); // { name: 'Alice', age: 29 }
// Later, you can create backups
await store.createBackup();
}
runExample();
Key Points
syncIntervalMs
automatically callsstore.sync()
periodically, pushing memory changes to SQLite.- You can manually call
store.sync()
whenever you want to persist changes immediately. - If you enable versioning (see Versioning), the store’s version is also saved in the database.
To store your data in a file (JSON-based):
import { KeyValueStore, FileAdapter } from '@bnk/kv-store';
async function runFileExample() {
const adapter = new FileAdapter({ filePath: 'my-store.json' });
const store = new KeyValueStore({
adapter,
syncIntervalMs: 3000
});
await store.init();
store.set('todoItems', ['Buy milk', 'Clean house', 'Pay bills']);
// Save to file
await store.sync();
}
runFileExample();
Key Points
- All data is kept in a JSON file that is read on
init()
and written on eachsync()
. - This can be perfect for storing small amounts of data like config files, settings, or user preferences.
Hooks let you observe or react to changes in the store:
onUpdate(key, value)
– Called when a key is set or updatedonDelete(key)
– Called when a key is deletedonBackup(timestamp, version)
– Called after a backup operation
import { KeyValueStore } from '@bnk/kv-store';
const store = new KeyValueStore({
hooks: {
onUpdate: (key, newValue) => {
console.log(`Key "${key}" updated to:`, newValue);
},
onDelete: (key) => {
console.log(`Key "${key}" was deleted`);
},
onBackup: (timestamp, version) => {
console.log(`Backup created at ${timestamp} with version ${version}`);
}
}
});
store.set('counter', 1);
store.delete('counter');
These hooks are non-blocking: any errors are caught internally so they don't disrupt store operations, but they will be logged in the console if they fail.
Validation is an optional but powerful feature. You can add a validator whenever you set or get a key, ensuring the data meets certain criteria.
import { KeyValueStore, type ValueValidator } from '@bnk/kv-store';
const isNumber: ValueValidator<number> = (val) => {
if (typeof val !== 'number') {
throw new Error('Value must be a number!');
}
return val;
};
const store = new KeyValueStore({});
store.set('count', 123, { validator: isNumber }); // OK
// This will throw an error
store.set('count', 'not a number', { validator: isNumber });
If you prefer a more robust schema-based approach, use Zod or any similar library:
import { z } from 'zod';
import { KeyValueStore, ValueValidator } from '@bnk/kv-store';
const userSchema = z.object({
name: z.string(),
age: z.number()
});
type User = z.infer<typeof userSchema>;
const userValidator: ValueValidator<User> = (val) => {
return userSchema.parse(val);
};
const store = new KeyValueStore({});
store.set('user', { name: 'Brandon', age: 30 }, { validator: userValidator });
// If the value doesn’t match the schema, an error is thrown.
You can also validate data on retrieval, which is helpful if the data might have changed in a way that no longer meets the schema:
const user = store.get<User>('user', { validator: userValidator });
console.log(user?.name, user?.age); // Safe to use
If your Bun application needs to manage configuration settings:
// config-store.ts
import { KeyValueStore, FileAdapter } from '@bnk/kv-store';
const configStore = new KeyValueStore({
adapter: new FileAdapter({ filePath: 'app-config.json' }),
syncIntervalMs: 5000,
enableVersioning: true
});
export async function initConfigStore() {
await configStore.init();
}
// Save a config key
export function setConfigKey<T>(key: string, value: T) {
configStore.set<T>(key, value);
}
// Get a config key
export function getConfigKey<T>(key: string): T | undefined {
return configStore.get<T>(key);
}
// Force immediate persistence
export async function saveConfig() {
await configStore.sync();
}
In another file, you might use it like so:
import { initConfigStore, setConfigKey, getConfigKey, saveConfig } from './config-store';
async function main() {
await initConfigStore();
setConfigKey('apiUrl', 'https://api.example.com');
setConfigKey('retryCount', 3);
console.log(getConfigKey<string>('apiUrl')); // "https://api.example.com"
console.log(getConfigKey<number>('retryCount')); // 3
// Force save immediately
await saveConfig();
}
main();
Using a file adapter or SQLite adapter, we can store to-do items:
// todo-app.ts
import { KeyValueStore, SqliteAdapter } from '@bnk/kv-store';
const store = new KeyValueStore({
adapter: new SqliteAdapter({ path: 'todo.db' }),
syncIntervalMs: 10000
});
type Todo = {
id: number;
title: string;
completed: boolean;
};
export async function initTodoStore() {
await store.init();
}
export async function addTodo(title: string) {
const todos = store.get<Todo[]>('todos') || [];
const newId = todos.length ? todos[todos.length - 1].id + 1 : 1;
const newTodo: Todo = { id: newId, title, completed: false };
todos.push(newTodo);
store.set('todos', todos);
await store.sync();
return newTodo;
}
export function listTodos(): Todo[] {
return store.get<Todo[]>('todos') || [];
}
export async function markComplete(id: number) {
const todos = store.get<Todo[]>('todos') || [];
const todo = todos.find(t => t.id === id);
if (todo) {
todo.completed = true;
store.set('todos', todos);
await store.sync();
}
return todo;
}
You could create a small CLI (using process.argv
) to interact with these functions. Each call is in-memory, with a periodic or forced sync to SQLite.
When versioning is enabled, each data mutation increments an internal counter. You can then query this version number (useful for concurrency checks or data synchronization tasks):
import { KeyValueStore } from '@bnk/kv-store';
const store = new KeyValueStore({ enableVersioning: true });
console.log(store.getVersion()); // 0
store.set('key1', 'value1');
console.log(store.getVersion()); // 1
store.delete('key1');
console.log(store.getVersion()); // 2
If using an adapter, the version is also stored and retrieved upon store.init()
, so it’s kept in sync across restarts.
Both the SQLite and file adapters have a built-in backup()
method that copies the database or JSON file to a timestamped backup. You trigger it via:
await store.createBackup();
If you have defined an onBackup
hook, it will trigger automatically when the backup is successful:
const store = new KeyValueStore({
adapter: new FileAdapter({ filePath: 'my-store.json' }),
hooks: {
onBackup: (timestamp, version) => {
console.log(`Backup at ${timestamp}, store version: ${version}`);
}
}
});
-
Set Up Proper Error Handling
- Although the library catches hook errors, you should still handle potential I/O failures (e.g., disk full, invalid JSON, corrupted database files).
-
Use Validation Where Possible
- This ensures your data remains consistent and typed.
-
Call
dispose()
If Necessary- If you have a continuous process, you might not need this. But in short-lived scripts, you can call
store.dispose()
to clear thesyncInterval
timer or other resources.
- If you have a continuous process, you might not need this. But in short-lived scripts, you can call
-
Test Thoroughly
- The library is well-tested, but your usage might differ. Use the provided test functions or your own test suite to ensure your adapters and logic are correct.
-
Periodically Backup
- If data is critical, make sure to call
store.createBackup()
regularly or rely on external backup mechanisms. The built-in backup approach simply copies the underlying file or database.
- If data is critical, make sure to call
Below is a simple illustration of how you could create a minimal server storing sessions (or tokens) in memory with optional file persistence.
// server.ts
import { serve } from 'bun';
import { KeyValueStore, FileAdapter } from '@bnk/kv-store';
const sessionStore = new KeyValueStore({
adapter: new FileAdapter({ filePath: 'session-data.json' }),
enableVersioning: true
});
await sessionStore.init();
serve({
port: 3000,
async fetch(request: Request) {
const url = new URL(request.url);
if (url.pathname === '/create-session') {
// Just a silly random token
const sessionId = Math.random().toString(36).substring(2);
sessionStore.set(`session-${sessionId}`, { createdAt: Date.now() });
await sessionStore.sync();
return new Response(`Session created with ID: ${sessionId}`);
}
if (url.pathname.startsWith('/get-session/')) {
const sessionId = url.pathname.split('/').pop();
const sessionData = sessionStore.get(`session-${sessionId}`);
return new Response(JSON.stringify(sessionData, null, 2), {
headers: { 'Content-Type': 'application/json' }
});
}
return new Response('Not found', { status: 404 });
}
});
- Create a new session by visiting http://localhost:3000/create-session.
- Retrieve session data by going to http://localhost:3000/get-session/.
All sessions are written to memory and (optionally) persisted to session-data.json
after each update.
class KeyValueStore {
constructor(config: KeyValueStoreConfig);
// Initialize store and load any existing data from adapter
init(): Promise<void>;
// Get a value by key
get<T>(key: string, options?: { validator?: ValueValidator<T> }): T | undefined;
// Set a value by key
set<T>(key: string, value: unknown, options?: { validator?: ValueValidator<T> }): T;
// Delete a value by key
delete(key: string): void;
// Force sync to external storage
sync(): Promise<void>;
// Create a backup (if adapter supports it)
createBackup(): Promise<void>;
// Retrieve current version (returns -1 if versioning disabled)
getVersion(): number;
// Clean up resources (e.g., clear intervals)
dispose(): void;
}
Adapters
SqliteAdapter
— syncs with an SQLite DB.FileAdapter
— syncs with a JSON file.
Each adapter has its own configuration, e.g.:
class SqliteAdapter {
constructor(config: { path: string, tableName?: string });
// ...
}
class FileAdapter {
constructor(config: { filePath: string });
// ...
}
@bnk/kv-store
offers a flexible, extensible way to manage key-value data in Bun. It’s designed with simplicity, performance, and testability in mind. Whether you need a quick in-memory cache or a more robust, validated, and versioned store with SQLite or file persistence, this library has you covered.