npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/settings

2. 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 via FileSystemToken. Preferred on desktop (Tauri, Electron). Creates the directory on first save. Cross-window sync via BroadcastChannel.
  • StorageService (createKvBackend) — uses StorageToken (localStorage on web). Data stored as JSON string. Cross-window sync via native storage event.
  • 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 storage event, which fires in other tabs/windows of the same origin whenever localStorage is mutated. Zero setup required.
  • FileSystem backend (Electron/Tauri) — uses BroadcastChannel to 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 StorageService implementations
  • Memory backend is the fallback — settings always work, even without capabilities

Migration Safety

  • runMigrations() validates the migrations array length before running — throws CoreError with MISSING_MIGRATION code 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
  • _version field 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
  • _version is 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 entrypoint

File 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.md

License

MIT