Basic Usage
import { table } from '@vielzeug/vault';
type User = { id: number; name: string; age: number };
type Post = { id: number; title: string; userId: number };
const schema = {
users: table<User>('id'),
posts: table<Post>('id'),
};table<T>(key) captures both the record type and the primary-key field name. The TypeScript compiler enforces that key is a valid field of T, and downstream operations — get, delete, has, upsert — accept only the correct key type.
You can set a per-table default TTL with .ttl():
import { table, ttl } from '@vielzeug/vault';
const schema = {
// every write to sessions uses a 30-minute TTL unless overridden at the call site
sessions: table<Session>('id').ttl(ttl.minutes(30)),
};Create an Adapter
All four factories return the same Adapter<S> interface and accept the same optional plugin options.
LocalStorage
import { createLocalStorage } from '@vielzeug/vault';
const db = createLocalStorage({ name: 'app', schema });SessionStorage
import { createSessionStorage } from '@vielzeug/vault';
const db = createSessionStorage({ name: 'app', schema });IndexedDB
import { createIndexedDB } from '@vielzeug/vault';
const db = createIndexedDB({ name: 'app', schema, version: 1 });Memory
import { createMemory } from '@vielzeug/vault';
const db = createMemory({ schema });No browser APIs required. Use this in unit tests and SSR environments.
Pass an optional name to enable cross-tab (or cross-window) synchronisation via BroadcastChannel. All createMemory instances with the same name in the same origin replicate mutations to each other.
const db = createMemory({ name: 'shared-state', schema });If BroadcastChannel is not available in the environment, the option is silently ignored.
Basic CRUD
// write
await db.put('users', { id: 1, name: 'Alice', age: 30 });
await db.putAll('users', [
{ id: 2, name: 'Bob', age: 25 },
{ id: 3, name: 'Carol', age: 28 },
]);
// read
const alice = await db.get('users', 1); // User | undefined
const all = await db.getAll('users'); // User[]
const total = await db.count('users'); // number (live records only)
const exists = await db.has('users', 1); // boolean
// update — merges fields, returns undefined if key does not exist
const updated = await db.update('users', 1, { age: 31 });
// delete
await db.delete('users', 1);
await db.clear('users');
(void alice, all, total, exists, updated);update returns the merged record, or undefined when the key is not found. Use upsert for insert-or-update semantics.
Key and Entry Reads
keys(table) returns the primary key of every live record without fetching the full records. Useful for existence checks, diffing, and cache invalidation.
const ids = await db.keys('users'); // number[]entries(table) returns all [key, record] pairs in a single call.
const pairs = await db.entries('users'); // [number, User][]Both are also available inside batch() callbacks.
isEmpty(table) returns true when a table has no live records — equivalent to (await db.count(table)) === 0, including TTL-expired records being treated as absent.
if (await db.isEmpty('users')) {
await db.putAll('users', defaultUsers);
}Bulk Key Lookup
getMany(table, keys) fetches multiple records in a single call. Missing keys return undefined. The result array preserves the input key order.
const [alice, missing, carol] = await db.getMany('users', [1, 99, 3]);
// → [User, undefined, User]getMany is also available inside batch() callbacks.
Use TTL
TTL must always be specified via the ttl helper. Raw numbers are rejected at the type level.
import { ttl } from '@vielzeug/vault';
await db.put('users', { id: 1, name: 'Alice', age: 30 }, ttl.minutes(5));
await db.put('users', { id: 2, name: 'Bob', age: 25 }, ttl.hours(24));
await db.put('users', { id: 3, name: 'Carol', age: 28 }, ttl.days(7));
await db.put('users', { id: 4, name: 'Dave', age: 22 }, ttl.ms(500));Expired records are evicted lazily on the next read. count() and getAll() exclude them.
Prune Expired Records
For write-heavy tables that are rarely read, expired records accumulate without lazy eviction. pruneExpired() sweeps tables explicitly and returns the count deleted per table.
// Prune all tables
const pruned = await db.pruneExpired();
// { users: 42, sessions: 10 }
// Prune only specific tables
const partial = await db.pruneExpired(['sessions']);
// { sessions: 10, users: 0 }Schedule periodic pruning with scheduleExpiredPrune:
import { scheduleExpiredPrune, ttl } from '@vielzeug/vault';
const stop = scheduleExpiredPrune(db, { interval: ttl.hours(1) });
// on teardown (before dispose)
stop();Pass signal to tie the schedule lifetime to an AbortController or db.disposalSignal — no manual stop() call needed:
scheduleExpiredPrune(db, {
interval: ttl.hours(1),
signal: db.disposalSignal, // auto-stops when the adapter is disposed
});The schedule also stops automatically if the adapter is disposed before the timer fires — VaultDisposedError thrown by pruneExpired() is caught and the interval is cleared.
On IndexedDB, pruning uses a cursor-based pass — expired records are deleted without loading them into memory. On LocalStorage / SessionStorage and Memory, expired records are detected and removed during the scan.
Upsert
upsert performs a read-modify-write atomically. The callback receives the current record (or undefined) and must return the new record.
// increment a counter even if the record doesn't yet exist
await db.upsert('users', 42, (existing) => ({
id: 42,
name: existing?.name ?? 'Unknown',
age: (existing?.age ?? 0) + 1,
}));Query Data
Queries are lazy pipelines that execute on the terminal call.
const page = await db
.query('users')
.between('age', 18, 99)
.startsWith('name', 'a', { ignoreCase: true })
.orderBy('name', 'asc')
.limit(20)
.offset(0)
.toArray();
const first = await db.query('users').orderBy('age', 'asc').first();
// count() respects limit/offset — returns the number of records in the current page
const count = await db.query('users').equals('age', 30).limit(10).count();
// totalCount() ignores limit/offset/orderBy — returns the full filtered set size
const total = await db.query('users').equals('age', 30).totalCount();
(void page, first, count, total);Use totalCount() alongside limit/offset for "page X of N" UIs:
const pageSize = 20;
const pageIndex = 2;
const q = db.query('users').between('age', 18, 99).orderBy('name', 'asc');
const [page, total] = await Promise.all([
q
.limit(pageSize)
.offset(pageIndex * pageSize)
.toArray(),
q.totalCount(),
]);
console.log(`Page ${pageIndex + 1} of ${Math.ceil(total / pageSize)}`, page);Delete via Query
const deleted = await db
.query('users')
.filter((u) => u.age < 18)
.delete();Returns the number of deleted records.
Lazy Iteration (IndexedDB)
iterate(table) is available on the IndexedDbAdapter returned by createIndexedDB. It streams records via an IDB cursor — the full table is never loaded into memory.
For memory and web storage adapters, use getAll() or query().toArray() instead.
import { createIndexedDB, table } from '@vielzeug/vault';
const db = createIndexedDB({ name: 'app', schema, version: 1 });
for await (const user of db.iterate('users')) {
if (user.age >= 18) {
await processUser(user);
}
}Expired records are skipped automatically. Each call opens a fresh readonly IDB transaction.
Reactive Reads
observe
observe(table, listener) subscribes to table changes. It always fires immediately with the current table state on registration, then fires again on every mutation. There is no deferred-first-call mode.
// Always fires immediately, then on every change
const stop = db.observe('users', (rows) => {
console.log('users snapshot:', rows.length);
});
await db.put('users', { id: 1, name: 'Alice', age: 30 }); // triggers listener again
stop();Pass { signal } to cancel the observer via an AbortController — a clean alternative to storing and calling the returned stop function.
const controller = new AbortController();
db.observe('users', (rows) => render(rows), { signal: controller.signal });
// later:
controller.abort(); // stops the observerAlways unsubscribe on teardown to prevent memory leaks.
watch — AsyncIterable Stream
watch(table) returns an AsyncIterable that yields a fresh snapshot on every change, always starting with an immediate snapshot. It is the for await companion to observe.
for await (const users of db.watch('users')) {
renderTable(users);
}The observer is cleaned up automatically when the loop exits via break, return, or an unhandled error.
Stop the loop from outside using an AbortSignal:
const controller = new AbortController();
for await (const users of db.watch('users', { signal: controller.signal })) {
renderTable(users);
}
controller.abort(); // terminates the loopBy default (mode: 'latest') intermediate snapshots are dropped if the consumer lags. Pass mode: 'all' to queue every snapshot instead.
toReadableStream — ReadableStream
To get a ReadableStream of snapshots, wrap db.watch() with toReadableStream(). Use it with WHATWG stream pipelines or in environments that consume ReadableStream directly.
import { toReadableStream } from '@vielzeug/vault';
toReadableStream(db.watch('users')).pipeTo(new WritableStream({ write: (users) => render(users) }));Always cancel the stream (or pass a signal to watch()) to stop the underlying observer:
const controller = new AbortController();
const stream = toReadableStream(db.watch('users', { signal: controller.signal }));
const reader = stream.getReader();
for (;;) {
const { value: users, done } = await reader.read();
if (done) break;
render(users);
}
await reader.cancel(); // unsubscribes the observerThe same mode and signal options as watch() apply.
observeMany — Combined Multi-Table Snapshot
observeMany(tables, listener) subscribes to multiple tables at once and delivers a single combined snapshot { [tableName]: RecordOf<S, T>[] } whenever any observed table changes.
All per-table observers fire immediately on registration. The combined listener fires once all tables have reported their initial snapshot, ensuring the combined view is always complete.
const stop = db.observeMany(['users', 'posts'], ({ users, posts }) => {
renderDashboard(users, posts);
});Writes to multiple tables inside a single batch() call coalesce into one callback — the listener fires exactly once per microtask, not once per dirty table.
Pass { signal } to cancel all observers at once:
const controller = new AbortController();
db.observeMany(['users', 'posts'], ({ users, posts }) => renderDashboard(users, posts), {
signal: controller.signal,
});
controller.abort();
tablesmust be non-empty. Passing an empty array throwsVaultScopeError.
Batch Writes
batch(tables, tx => ...) defers all observer notifications until the callback resolves. On IndexedDB it also runs inside a real atomic IDB transaction.
await db.batch(['users', 'posts'], async (tx) => {
await tx.put('users', { id: 1, name: 'Alice', age: 30 });
await tx.put('posts', { id: 10, title: 'Hello', userId: 1 });
});query() and query().delete() are also available inside batch() callbacks:
await db.batch(['users', 'posts'], async (tx) => {
await tx.put('users', { id: 1, name: 'Alice', age: 30 });
// query and delete inside the batch scope
await tx
.query('posts')
.filter((p) => p.title.startsWith('H'))
.delete();
});batch() is table-scoped at runtime and type level:
- the table list must not be empty
- inside
tx, operations on tables not included intablesthrowVaultScopeError
getOrDefault is also available inside batch() callbacks — it is a read-or-insert: returns the existing record if present, otherwise calls defaultFn(), writes and returns the result.
await db.batch(['users'], async (tx) => {
const user = await tx.getOrDefault('users', 42, () => ({ id: 42, name: 'Guest', age: 0 }));
// user is either the existing record or the newly inserted default
});For IndexedDB, getOrDefault inside batch() is atomic — the check and insert happen inside the same IDB transaction. For Memory and WebStorage adapters, it is a logical read-then-write but not physically atomic.
If the callback throws on IndexedDB, the IDB transaction is rolled back automatically. On other adapters the callback's side effects are not rolled back, but no observer notifications are fired.
Debug
debug() returns live vs expired record counts per table. Useful during development.
const info = await db.debug();
for (const table of info.tables) {
console.log(table.name, '— live:', table.recordCount, 'expired:', table.expiredCount);
}Handle Schema Migrations (IndexedDB)
import { createIndexedDB, type MigrationFn } from '@vielzeug/vault';
const migrate: MigrationFn = ({ db, oldVersion, tx }) => {
if (oldVersion < 2 && db.objectStoreNames.contains('users')) {
tx.objectStore('users').createIndex('name', 'name', { unique: false });
}
};
const db = createIndexedDB({
name: 'app',
migrate,
schema,
version: 2,
});
void db;Plugins
All four adapters accept the same optional plugin options at construction time. Each plugin uses a structural interface — pass the real package object directly, no adapter or wrapper needed.
Reactive Signals (signals)
Wire @vielzeug/ripple signals directly. Each signal is automatically kept in sync with its table via observe() and cleaned up on dispose().
import { signal } from '@vielzeug/ripple';
import { createMemory } from '@vielzeug/vault';
const usersSignal = signal<User[]>([]);
const db = createMemory({
schema,
signals: { users: usersSignal },
});
// usersSignal.value is now always in sync with the users tableAny object with an update(fn: (current: T) => T): void method satisfies ReactiveSignal<T> structurally. @vielzeug/ripple Signal<T> and Store<T> both satisfy this directly.
Logger (logger)
Pass a @vielzeug/rune logger or any object with an error(...) method. Observer notification errors are routed to logger.error.
import { createLogger } from '@vielzeug/rune';
import { createMemory } from '@vielzeug/vault';
const db = createMemory({
schema,
logger: createLogger('db'),
});Record Validation (validators)
Pass a @vielzeug/spell schema or any object with parse(value): T. Validators run before every put, putAll, update, and upsert.
import { s } from '@vielzeug/spell';
import { createMemory } from '@vielzeug/vault';
const db = createMemory({
schema,
validators: {
users: s.object({ id: s.number(), name: s.string(), age: s.number() }),
},
});Any value that fails parse causes the write to throw before touching storage.
Metrics (onMetrics)
onMetrics is called after every operation with timing and table name. Use it for performance monitoring.
const db = createMemory({
schema,
onMetrics: (event) => {
console.log(`[${event.table}] ${event.operation} — ${event.duration}ms`);
},
});Tracked operations: get, getAll, getMany, getOrDefault, keys, entries, has, put, putAll, deleteMany, count, delete, clear, update, upsert, batch, query, queryDelete.
For batch operations, event.table is '*' because a batch may span multiple tables.
Quota Exceeded Hook (onQuotaExceeded) — LocalStorage / SessionStorage
Called when a write exceeds the storage quota. The callback receives a VaultQuotaError. Return 'ignore' to silently drop the write, or 'throw' (default) to rethrow.
import { createLocalStorage, type VaultQuotaError } from '@vielzeug/vault';
const db = createLocalStorage({
name: 'app',
schema,
onQuotaExceeded: (table, error: VaultQuotaError) => {
console.warn(`[${String(table)}] quota exceeded — dropping write`, error.message);
return 'ignore';
},
});Environment-Based Adapter Selection
All factories return the same Adapter<S> type, making it straightforward to select a backend at runtime.
import { createIndexedDB, createLocalStorage, createMemory, type Adapter } from '@vielzeug/vault';
function createStorage(): Adapter<typeof schema> {
if (typeof indexedDB !== 'undefined') {
return createIndexedDB({ name: 'app', schema, version: 1 });
}
if (typeof localStorage !== 'undefined') {
return createLocalStorage({ name: 'app', schema });
}
return createMemory({ schema });
}Lifecycle
Call dispose() when the adapter is no longer needed. This disconnects observers, signal subscriptions, the BroadcastChannel (IDB), and the IDB connection.
db.dispose();Error Handling
All errors thrown by @vielzeug/vault are instances of VaultError. Catch the base class to handle any vault-originated error, or catch specific subclasses for fine-grained handling.
import { VaultDisposedError, VaultError, VaultMigrationError, VaultQuotaError, VaultScopeError } from '@vielzeug/vault';
try {
await db.put('users', { id: 1, name: 'Alice', age: 30 });
} catch (err) {
if (err instanceof VaultDisposedError) {
// operation on a disposed adapter
} else if (err instanceof VaultScopeError) {
// table not in batch() scope, or empty tables array passed to observeMany
} else if (err instanceof VaultQuotaError) {
// WebStorage quota exceeded (also sent to onQuotaExceeded if configured)
} else if (err instanceof VaultMigrationError) {
// IndexedDB onupgradeneeded migration threw
} else if (err instanceof VaultError) {
// any other vault error
}
}| Class | Thrown when |
|---|---|
VaultError | Base class — catch all vault errors |
VaultDisposedError | Any operation after dispose() |
VaultScopeError | batch() accesses a table outside its declared scope; empty array passed to observeMany |
VaultQuotaError | A LocalStorage / SessionStorage write exceeds the storage quota |
VaultMigrationError | IndexedDB onupgradeneeded migration callback threw |
Framework Integration
Use db.observe() for framework subscriptions. The returned unsubscribe function maps directly to each framework's cleanup hook.
import { useEffect, useState } from 'react';
import { createMemory, table } from '@vielzeug/vault';
type User = { id: number; name: string };
const schema = { users: table<User>('id') };
const db = createMemory({ schema });
function useUsers(): User[] {
const [users, setUsers] = useState<User[]>([]);
useEffect(() => {
// observe always fires immediately — setUsers is called once on mount, then on each change
return db.observe('users', setUsers);
}, []);
return users;
}import { onScopeDispose, ref } from 'vue';
import { createMemory, table } from '@vielzeug/vault';
import type { Ref } from 'vue';
type User = { id: number; name: string };
const schema = { users: table<User>('id') };
const db = createMemory({ schema });
export function useUsers(): { users: Ref<User[]> } {
const users = ref<User[]>([]);
// observe always fires immediately — users.value is populated on composition
const stop = db.observe('users', (rows) => {
users.value = rows;
});
onScopeDispose(stop);
return { users };
}<script lang="ts">
import { onDestroy } from 'svelte';
import { createMemory, table } from '@vielzeug/vault';
type User = { id: number; name: string };
const schema = { users: table<User>('id') };
const db = createMemory({ schema });
let users: User[] = [];
const stop = db.observe('users', (rows) => { users = rows; });
onDestroy(stop);
</script>
{#each users as user}
<p>{user.name}</p>
{/each}For libraries with a reactive context (scope, onUnmounted, signal effects), you can also use db.watch(table) inside an async loop — the observer is cleaned up automatically when the loop exits.
Working with Other Vielzeug Libraries
With Ripple
Wire a @vielzeug/ripple signal to a table at construction time. The signal is updated automatically on every table change — no observe() boilerplate required.
import { signal, effect } from '@vielzeug/ripple';
import { createIndexedDB, table } from '@vielzeug/vault';
type User = { id: number; name: string };
const schema = { users: table<User>('id') };
const usersSignal = signal<User[]>([]);
const db = createIndexedDB({
name: 'app',
schema,
signals: { users: usersSignal },
version: 1,
});
// usersSignal.value stays in sync with the users table automatically
effect(() => console.log('users:', usersSignal.value.length));
await db.put('users', { id: 1, name: 'Alice' }); // → effect re-runsWith Spell
Pass a @vielzeug/spell schema as a validator. Vault calls schema.parse(value) before every put, putAll, update, and upsert. Invalid records throw without touching storage.
import { s } from '@vielzeug/spell';
import { createMemory, table } from '@vielzeug/vault';
type User = { id: number; name: string; age: number };
const schema = { users: table<User>('id') };
const db = createMemory({
schema,
validators: {
users: s.object({ id: s.number(), name: s.string(), age: s.number().min(0) }),
},
});
// throws a spell validation error before writing
await db.put('users', { id: 1, name: 'Alice', age: -1 });With Rune
Pass a @vielzeug/rune logger to route observer notification errors to your structured log pipeline.
import { createLogger } from '@vielzeug/rune';
import { createIndexedDB, table } from '@vielzeug/vault';
type User = { id: number; name: string };
const schema = { users: table<User>('id') };
const db = createIndexedDB({
name: 'app',
schema,
version: 1,
logger: createLogger('vault'),
});Best Practices
- Prefer
createIndexedDBfor large datasets or flows that require atomicity. - Use
createMemoryin tests — no cleanup, no browser API requirements. - Always call the
observe()unsubscribe function on component teardown (or usewatch()which auto-unsubscribes on loop exit). - Use
batch()when writing to multiple tables to batch observer notifications. - Use
ttl.*helpers — raw millisecond numbers are rejected by the type system. - Keep
batch()callbacks focused on storage operations; avoid arbitrary async side effects. - Schedule
scheduleExpiredPrunefor write-heavy tables with TTL to reclaim storage proactively.