@enclosurejs/settings
v1.2.0
Published
Persistent user preferences with schema migrations for Enclosure apps
Readme
@enclosurejs/settings — Persistent user preferences with schema migrations and cross-window sync
[!IMPORTANT] This package provides typed, versioned, storage-agnostic user preferences with change subscriptions, debounced persistence, and automatic cross-window synchronization. One module — schema migrations, deep merge, reactive UI binding. Zero external dependencies. Works with any backend.
The Problem
Desktop and web applications need user preferences that survive restarts, support schema evolution, and notify the UI on change. Rolling your own means duplicated storage logic, ad-hoc migration scripts, and mutable state that drifts silently. There is no standard module for typed, versioned, storage-agnostic settings.
@enclosurejs/settings solves this with createSettingsModule() — a module that loads settings from storage, runs sequential migrations, deep-merges with defaults (for new fields), and exposes a SettingsService with get/set/patch/reset/onChange. Persistence is debounced to avoid thrashing disk. Storage backend is resolved from DI capabilities: FileSystem > StorageService > in-memory fallback. When multiple windows share the same storage, changes are automatically propagated via StorageBackend.subscribe — no polling, no manual wiring.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Install (async) │
│ │
│ ┌────────────┐ ┌────────────┐ ┌──────────────────────┐ │
│ │ FileSystem │ │ StorageSvc │ │ Memory (fallback) │ │
│ └─────┬──────┘ └─────┬──────┘ └──────────┬───────────┘ │
│ └────────┬────────┴─────────────────────┘ │
│ ▼ │
│ backend.load() │
│ │ │
│ ▼ │
│ runMigrations(data, v1 → vN) │
│ │ │
│ ▼ │
│ deepMerge(defaults, migrated) │
│ │ │
│ ▼ │
│ SettingsServiceImpl(data, backend, debounce) │
│ │ │
│ ctx.provide(SettingsToken, service) │
└─────────────────┼───────────────────────────────────────────────┘
▼
┌─────────────────────────────────────────────────────────────────┐
│ Runtime │
│ │
│ const svc = ctx.use(SettingsToken) │
│ svc.get('theme') // "dark" │
│ svc.set('theme', 'light') // fires onChange + debounced save │
│ svc.patch({ editor: { tabSize: 2 } }) // deep merge │
│ svc.reset() // restore defaults + persist │
│ svc.onChange('theme', (next, prev) => { ... }) │
│ svc.current // frozen snapshot │
└─────────────────────────────────────────────────────────────────┘Storage priority: FileSystemToken > StorageToken > in-memory fallback.
Dependency rule: @enclosurejs/settings imports only @enclosurejs/core (peer). No external runtime dependencies.
Quick Start
1. Install
pnpm add @enclosurejs/settings2. Module usage
import { createApp } from '@enclosurejs/core';
import { createSettingsModule, SettingsToken } from '@enclosurejs/settings';
const app = createApp({
modules: [
createConfigModule({ defaults: { ... } }),
createSettingsModule({
schemaVersion: 2,
defaults: {
theme: 'light',
fontSize: 14,
editor: { tabSize: 4, wordWrap: true },
},
migrations: [
(prev) => {
const { darkMode, ...rest } = prev;
return { ...rest, theme: darkMode ? 'dark' : 'light' };
},
],
}),
],
});3. Consuming settings
const settingsModule: Module = {
id: 'ui',
requires: ['settings'],
install(ctx) {
const svc = ctx.context.use(SettingsToken);
svc.onChange('theme', (theme) => applyTheme(theme as string));
svc.set('fontSize', 16);
svc.patch({ editor: { tabSize: 2 } });
svc.reset();
},
};How It Works
Storage Backends
Three backends are provided, selected automatically from DI capabilities at install time:
- FileSystem (
createFsBackend) — reads/writes a JSON file viaFileSystemToken. Preferred on desktop (Tauri, Electron). Creates the directory on first save. Cross-window sync viaBroadcastChannel. - StorageService (
createKvBackend) — usesStorageToken(localStorage on web). Data stored as JSON string. Cross-window sync via nativestorageevent. - Memory (
createMemoryBackend) — no persistence across restarts. No cross-window sync. Used when no capabilities are available (tests, CI).
Schema Migrations
runMigrations() runs sequential transforms: migrations[0] converts v1→v2, migrations[1] converts v2→v3, etc. Throws CoreError with code MISSING_MIGRATION when the migrations array is too short for the version gap. Stored data includes a _version field for version tracking.
Deep Merge
deepMerge() recursively merges plain objects — arrays, primitives, null, and undefined are replaced wholesale. Prototype-pollution keys (__proto__, constructor, prototype) are silently skipped. Used both during install (to add new default fields to stored data) and in patch().
Debounced Persistence
set() and patch() schedule a debounced persist (default 500ms). Rapid changes are batched into a single write. reset() persists immediately. flush() persists immediately and cancels any pending debounce — called automatically during module disposal.
Change Subscriptions
onChange(key, handler) subscribes to changes on a specific key. The handler receives (newValue, prevValue). Returns an unsubscribe function. patch() only fires handlers for keys that actually changed.
Cross-Window Synchronization
When an application opens multiple windows (Electron multi-window, browser tabs, Tauri windows), each window has its own App and its own SettingsServiceImpl instance. Without synchronization, a set('theme', 'dark') in one window would persist to storage but other windows would keep the stale in-memory value until restart.
StorageBackend.subscribe solves this with push-based notifications:
- KV backend (web) — listens to the native
storageevent, which fires in other tabs/windows of the same origin wheneverlocalStorageis mutated. Zero setup required. - FileSystem backend (Electron/Tauri) — uses
BroadcastChannelto notify other renderer windows in the same process. When one window saves, others receive the updated payload without polling the filesystem. - Memory backend — no sync (tests, CI).
The module wires this automatically: if backend.subscribe is defined, createSettingsModule subscribes during install and unsubscribes during dispose. External changes are applied via SettingsServiceImpl.applyExternal(), which fires onChange for every key that actually changed — without triggering a redundant persist.
// No application code needed — sync is automatic.
// Window A:
svc.set('theme', 'dark'); // persists + notifies other windows
// Window B (automatically receives):
svc.onChange('theme', (next) => applyTheme(next)); // fires with 'dark'Custom StorageBackend implementations can opt in by providing a subscribe method.
API
Exports (@enclosurejs/settings)
| Export | Kind | Purpose |
| ---------------------- | -------- | ----------------------------------------------------------- |
| createSettingsModule | function | Module factory — wires storage, migrations, service into DI |
| SettingsToken | token | DI token to resolve the settings service |
| SettingsServiceImpl | class | Service implementation (for advanced use / testing) |
| buildConfig | function | — (not exported, see @enclosurejs/config) |
| deepMerge | function | Recursive merge with prototype-pollution protection |
| runMigrations | function | Sequential schema transforms |
| createFsBackend | function | FileSystem storage backend |
| createKvBackend | function | StorageService (key-value) storage backend |
| createMemoryBackend | function | In-memory storage backend (no persistence) |
| SettingsOptions | type | Configuration for createSettingsModule |
| SettingsService | type | Service interface (get/set/patch/reset/onChange/current) |
| StorageBackend | type | Storage abstraction (load/save/subscribe) |
SettingsOptions<T>
| Field | Type | Required | Description |
| ----------------- | ------------------------------- | -------- | ------------------------------------------------------------- |
| schemaVersion | number | Yes | Current schema version (integer, starts at 1) |
| defaults | T | Yes | Full default settings — TypeScript infers T |
| migrations | readonly ((prev) => Record)[] | No | Ordered migration functions: index 0 = v1→v2, index 1 = v2→v3 |
| storageKey | string | No | Key for KV storage. Default: "enclosure-settings" |
| dir | string | No | Directory for FS storage. Default: "settings" |
| filename | string | No | Filename for FS storage. Default: "settings.json" |
| persistDebounce | number | No | Debounce interval in ms. Default: 500 |
SettingsService<T>
| Method / Property | Signature | Description |
| ------------------ | ------------------------------------------------------------------- | ----------------------------------------------------- |
| get(key) | <K extends keyof T>(key: K) => T[K] | Get a single setting |
| set(key, value) | <K extends keyof T>(key: K, value: T[K]) => void | Set a single setting + onChange + debounced persist |
| patch(partial) | (partial: Partial<T>) => void | Deep merge partial update + onChange for changed keys |
| reset() | () => void | Restore defaults + onChange + immediate persist |
| onChange(key, h) | <K>(key: K, handler: (v: T[K], prev: T[K]) => void) => () => void | Subscribe to key changes. Returns unsubscribe. |
| current | Readonly<T> | Frozen snapshot of current state |
Configuration
No config files. Only schemaVersion and defaults are required:
// Minimal
createSettingsModule({ schemaVersion: 1, defaults: { theme: 'light' } });
// With migrations
createSettingsModule({
schemaVersion: 2,
defaults: { theme: 'light', fontSize: 14 },
migrations: [(prev) => ({ ...prev, fontSize: 14 })],
});
// Custom storage location
createSettingsModule({
schemaVersion: 1,
defaults: { theme: 'light' },
dir: 'my-app/config',
filename: 'prefs.json',
storageKey: 'my-app-settings',
persistDebounce: 1000,
});Types Exported
| Type | Used by |
| ----------------- | ---------------------------------- |
| SettingsOptions | Module factories, test fixtures |
| SettingsService | Any code consuming settings via DI |
| StorageBackend | Custom storage implementations |
Safety
Storage Safety
- FileSystem backend creates the directory recursively on first save — no crash on missing dirs
- KV backend handles both string and object values from storage — tolerates different
StorageServiceimplementations - Memory backend is the fallback — settings always work, even without capabilities
Migration Safety
runMigrations()validates the migrations array length before running — throwsCoreErrorwithMISSING_MIGRATIONcode instead of silent data loss- Stored data is deep-merged with defaults after migration — new fields added in later schema versions are automatically populated
Deep Merge Safety
- Prototype-pollution keys (
__proto__,constructor,prototype) are silently skipped - Arrays are replaced wholesale (not concatenated) — predictable behavior
- Source and target are not mutated — fresh object returned
Persistence Safety
- Debounced writes prevent I/O thrashing on rapid changes
flush()on disposal ensures pending changes are persisted before shutdown_versionfield is always included in persisted data — version tracking is automatic
Cross-Window Safety
applyExternal()does not trigger a persist — prevents write loops between windows- Backends that lack browser APIs (
window,BroadcastChannel) gracefully skip sync (no runtime errors in Node/SSR) - Subscribe cleanup runs on module dispose — no leaked listeners
_versionis stripped from incoming data before merge — internal metadata stays internal
Benchmarks
Not applicable. @enclosurejs/settings loads once at startup and persists on user-triggered changes. There is no runtime hot path to benchmark. The debounce mechanism explicitly batches I/O.
Bundle Size
| Output | File | Size |
| ------------ | ------------ | -------- |
| Runtime (JS) | index.js | 8.26 KB |
| Types (DTS) | index.d.ts | 4.79 KB |
| Total | | 13.05 KB |
Single entrypoint — all exports bundled into one file. Zero runtime dependencies.
Quality
| Metric | Value |
| --------------------- | ------------------------------------------------------------------------------------------------------ |
| Unit tests | 90 (86 pass, 4 skipped — browser-only tests) |
| Test files | 5 (service.test.ts, deep-merge.test.ts, module.test.ts, storage.test.ts, migrations.test.ts) |
| Source files | 7 (index.ts, module.ts, service.ts, storage.ts, migrations.ts, deep-merge.ts, types.ts) |
| External dependencies | 0 |
| Peer dependencies | @enclosurejs/core |
| Coverage thresholds | statements >= 90%, branches >= 85%, functions >= 95%, lines >= 90% |
Quality Layers
Layer 1: STATIC ANALYSIS (every commit)
tsc --noEmit strict mode, zero errors
eslint ESLint 9 flat config, zero warnings
prettier --check formatting
Layer 2: UNIT TESTS (every commit)
90 tests service, deep-merge, migrations, storage backends, DI integration, cross-window sync
v8 coverage statements 99.6%, branches 97.4%, functions 100%, lines 99.6%
Layer 3: BENCHMARKS
N/A user-action-driven, debounced I/O — no hot path
Layer 4: PACKAGE HEALTH
0 external deps pure TypeScript + @enclosurejs/core
tsup build ESM + DTS output, single entrypointFile Structure
packages/settings/
├── src/
│ ├── index.ts Barrel: all public exports
│ ├── types.ts SettingsOptions<T>, SettingsService<T>, StorageBackend
│ ├── module.ts createSettingsModule(), SettingsToken
│ ├── service.ts SettingsServiceImpl — state, onChange, debounced persist
│ ├── storage.ts createFsBackend(), createKvBackend(), createMemoryBackend()
│ ├── migrations.ts runMigrations() — sequential schema transforms
│ ├── deep-merge.ts deepMerge() — recursive merge with prototype-pollution guard
│ └── __tests__/
│ ├── service.test.ts 28 tests — get/set/patch/reset/onChange/debounce/flush/applyExternal
│ ├── deep-merge.test.ts 19 tests — nested merge, arrays, null, proto pollution
│ ├── module.test.ts 12 tests — DI, storage backends, migrations, custom paths, sync wiring
│ ├── storage.test.ts 21 tests — Fs, Kv, Memory backends + cross-window subscribe
│ └── migrations.test.ts 10 tests — sequential, partial, missing, key transforms
├── package.json
├── tsconfig.json
├── tsup.config.ts
└── spec.mdLicense
MIT
