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

@mrbelloc/encrypted-store

v3.0.0

Published

Encrypted document storage with change detection using PouchDB and AES-256-GCM

Readme

Encrypted Store

Client-side encrypted document storage with change detection using PouchDB and AES-256-GCM encryption.

Simple API for offline-first apps - PUT, GET, DELETE documents with automatic sync to CouchDB.

Features

  • 🔐 AES-256-GCM encryption with WebCrypto API
  • 📦 Simple document API: put, get, delete, getAll
  • 🔄 Real-time change detection (onChange, onDelete)
  • ⚠️ Conflict detection and resolution
  • 🌐 Sync to CouchDB (or any PouchDB-compatible server)
  • 📊 Sync progress events
  • 🔌 Offline-first with automatic retry
  • 📱 Works in browser and Node.js
  • 🎯 TypeScript with full type safety

Installation

For Browser (Vite/Webpack)

npm install @mrbelloc/encrypted-store pouchdb-browser@^8.0.1 events

Note: Currently requires PouchDB v8. PouchDB v9 has compatibility issues with TypeScript types and some bundlers.

Required for Vite: Install the events package to fix "Class extends value [object Object]" errors.

For Node.js

npm install @mrbelloc/encrypted-store pouchdb

Quick Start

Browser (Vite/React/Vue/Svelte)

import PouchDBModule from 'pouchdb-browser';
// Workaround for ESM/CommonJS compatibility in some bundlers
const PouchDB = PouchDBModule.default || PouchDBModule;
import { EncryptedStore } from '@mrbelloc/encrypted-store';

// Create database and encrypted store (uses IndexedDB in browser)
const db = new PouchDB('myapp');
const store = new EncryptedStore(db, 'my-password', {
  onChange: (docs) => {
    console.log('Documents changed:', docs);
  },
  onDelete: (docs) => {
    console.log('Documents deleted:', docs);
  },
  onConflict: (conflicts) => {
    console.log('Conflicts detected:', conflicts);
  },
  onSync: (info) => {
    console.log('Sync progress:', info);
  },
  onError: (errors) => {
    console.error('Decryption errors:', errors);
  }
});

// Load existing data and start change detection
await store.loadAll();

// Create/update documents
await store.put('expenses', { 
  _id: 'lunch', 
  amount: 15.50, 
  date: '2024-01-15' 
});

// Get a document
const expense = await store.get('expenses', 'lunch');
console.log(expense); // { _id: 'lunch', _table: 'expenses', amount: 15.50, date: '2024-01-15' }

// Get all documents (optionally filtered by table)
const allExpenses = await store.getAll('expenses');
const allDocs = await store.getAll();

// Delete a document
await store.delete('expenses', 'lunch');

// Sync to CouchDB
await store.connectRemote({
  url: 'http://localhost:5984/myapp',
  live: true,
  retry: true
});

Node.js

import PouchDB from 'pouchdb';
import { EncryptedStore } from '@mrbelloc/encrypted-store';

// Create database and encrypted store (uses LevelDB in Node)
const db = new PouchDB('myapp');
const store = new EncryptedStore(db, 'my-password', {
  onChange: (docs) => console.log('Changed:', docs),
  onDelete: (docs) => console.log('Deleted:', docs),
});

await store.loadAll();

API Reference

new EncryptedStore(db, password, listener?, options?)

Creates an encrypted store.

  • db: PouchDB database instance
  • password: Encryption password (string)
  • listener: Optional object with callbacks
  • options: Optional configuration object

Options:

interface EncryptedStoreOptions {
  passphraseMode?: "derive" | "raw";  // default: "derive"
}
  • passphraseMode: "derive" (default): Uses PBKDF2 with 100k iterations for user passphrases. Recommended for production use. Provides strong protection against brute-force and dictionary attacks. First unlock will take ~50-100ms.
  • passphraseMode: "raw": Uses SHA-256 only. For pre-derived keys or advanced users who handle key derivation themselves. Allows full control over KDF algorithm, iterations, and progress UI.

Listener Callbacks

interface StoreListener {
  onChange: (docs: Doc[]) => void;
  onDelete: (docs: Doc[]) => void;
  onConflict?: (conflicts: ConflictInfo[]) => void;
  onSync?: (info: SyncInfo) => void;
  onError?: (errors: DecryptionErrorEvent[]) => void;
}
  • onChange(docs): Called when documents are added or updated
  • onDelete(docs): Called when documents are deleted
  • onConflict(conflicts): Called when conflicts are detected
  • onSync(info): Called during sync operations
  • onError(errors): Called when documents fail to decrypt

await store.loadAll()

Loads all existing documents and starts change detection. Call this once after creating the store.

await store.put(table, doc)

Creates or updates a document.

  • table: Document type (e.g., "expenses", "tasks")
  • doc: Document object with optional _id field (generated if missing)

Returns the document with _table field added.

await store.get(table, id)

Gets a document by table and ID. Returns null if not found.

await store.delete(table, id)

Deletes a document by table and ID.

await store.deleteAllLocal()

Deletes all documents locally only. Automatically disconnects sync first to prevent deletions from propagating to remote. Use this when you want to clear local data only.

// Clear all local data without affecting remote
await store.deleteAllLocal();

await store.deleteAllAndSync()

Deletes all documents locally AND propagates deletions to remote. Waits for sync to complete before returning. Throws an error if sync is not connected.

// Connect to remote first
await store.connectRemote({ url: 'http://localhost:5984/mydb' });

// Delete everything locally and remotely
await store.deleteAllAndSync();

Note: Call connectRemote() first, or use deleteAllLocal() instead.

await store.getAll(table?)

Gets all documents, optionally filtered by table.

const allExpenses = await store.getAll('expenses');
const allDocs = await store.getAll();

await store.connectRemote(options)

Connects to a remote CouchDB server for sync.

interface RemoteOptions {
  url: string;        // CouchDB URL
  live?: boolean;     // Continuous sync (default: true)
  retry?: boolean;    // Auto-retry on failure (default: true)
}

store.disconnectRemote()

Disconnects from remote sync.

await store.syncNow()

Triggers an immediate one-time sync with the remote. Useful for controlling sync timing, especially with rate-limited services like IBM Cloudant's free tier.

// Connect with continuous sync disabled
await store.connectRemote({
  url: 'http://localhost:5984/mydb',
  live: false,
  retry: false
});

// Manually trigger sync when needed
await store.syncNow();

// Example: Batch multiple changes then sync
await store.put('expenses', { _id: '1', amount: 10 });
await store.put('expenses', { _id: '2', amount: 20 });
await store.put('expenses', { _id: '3', amount: 30 });
await store.syncNow(); // Sync all changes at once

Throws an error if connectRemote() hasn't been called first.

store.reconnect()

Re-subscribes to changes. Useful after disconnect/reconnect scenarios or if the change feed needs to be restarted.

// Restart the change detection feed
store.reconnect();

await store.getConflictInfo(table, id)

Check if a document has conflicts without triggering the callback. Returns ConflictInfo if conflicts exist, or null if none.

const conflictInfo = await store.getConflictInfo('expenses', 'lunch');
if (conflictInfo) {
  console.log('Conflict detected!');
  console.log('Winner:', conflictInfo.winner);
  console.log('Losers:', conflictInfo.losers);
  // Handle the conflict
}

await store.resolveConflict(table, id, winningDoc)

Manually resolve a conflict by choosing the winning document.

// Option 1: Use in onConflict callback
store.listener.onConflict = async (conflicts) => {
  for (const conflict of conflicts) {
    // Pick the document with the latest timestamp
    const latest = [conflict.winner, ...conflict.losers]
      .sort((a, b) => b.timestamp - a.timestamp)[0];
    
    await store.resolveConflict(conflict.table, conflict.id, latest);
  }
};

// Option 2: Check manually and resolve
const conflict = await store.getConflictInfo('expenses', 'lunch');
if (conflict) {
  await store.resolveConflict('expenses', 'lunch', conflict.winner);
}

Conflict Detection

When the same document is edited offline on multiple devices, PouchDB detects conflicts automatically:

interface ConflictInfo {
  docId: string;         // Full document ID (e.g., "expenses_lunch")
  table: string;         // Document table (e.g., "expenses")
  id: string;            // Document ID (e.g., "lunch")
  currentRev: string;    // Current revision ID
  conflictRevs: string[];// Conflicting revision IDs
  winner: Doc;           // The winning document (current version)
  losers: Doc[];         // Conflicting versions
}

The onConflict callback gives you both the winner and all conflicting versions, so you can:

  • Show a UI for manual resolution
  • Auto-resolve based on timestamps
  • Merge changes programmatically
  • Log conflicts for review

Sync Events

Monitor sync progress with the onSync callback:

interface SyncInfo {
  direction: 'push' | 'pull' | 'both';
  change: {
    docs_read?: number;
    docs_written?: number;
    doc_write_failures?: number;
    errors?: any[];
  };
}

Example:

const store = new EncryptedStore(db, password, {
  onChange: (docs) => console.log('Changed:', docs.length),
  onDelete: (docs) => console.log('Deleted:', docs.length),
  onSync: (info) => {
    if (info.direction === 'push') {
      console.log(`Pushed ${info.change.docs_written} docs to server`);
    } else {
      console.log(`Pulled ${info.change.docs_read} docs from server`);
    }
  }
});

Deployment Options

Free Tier Options

  1. IBM Cloudant - Free tier: 1GB storage, 20 req/sec

    // Option 1: Continuous sync (may hit rate limits on initial sync)
    await store.connectRemote({
      url: 'https://username:[email protected]/mydb'
    });
    
    // Option 2: Manual sync control (recommended for rate-limited services)
    await store.connectRemote({
      url: 'https://username:[email protected]/mydb',
      live: false,
      retry: false
    });
    // Trigger sync manually when needed
    await store.syncNow();
  2. Oracle Cloud Free Tier - Run your own CouchDB

    # On Oracle VM
    docker run -d -p 5984:5984 -e COUCHDB_USER=admin -e COUCHDB_PASSWORD=password couchdb
  3. Self-hosted - CouchDB on any VPS ($5/month)

    await store.connectRemote({
      url: 'http://admin:[email protected]:5984/mydb'
    });

Backup Strategy

Example using Oracle Free Tier + S3:

# Daily backup script
#!/bin/bash
TODAY=$(date +%Y-%m-%d)
curl -X GET http://admin:password@localhost:5984/mydb/_all_docs?include_docs=true > backup-$TODAY.json
aws s3 cp backup-$TODAY.json s3://my-backups/couchdb/

Example: React Integration

import { useState, useEffect } from 'react';
import PouchDBModule from 'pouchdb-browser';
const PouchDB = PouchDBModule.default || PouchDBModule;
import { EncryptedStore } from '@mrbelloc/encrypted-store';

function useEncryptedStore(dbName: string, password: string) {
  const [expenses, setExpenses] = useState<Map<string, any>>(new Map());
  const [store, setStore] = useState<EncryptedStore | null>(null);

  useEffect(() => {
    const db = new PouchDB(dbName);
    const encryptedStore = new EncryptedStore(db, password, {
      onChange: (docs) => {
        setExpenses((prev) => {
          const next = new Map(prev);
          docs.forEach((doc) => {
            if (doc._table === 'expenses') {
              next.set(doc._id, doc);
            }
          });
          return next;
        });
      },
      onDelete: (docs) => {
        setExpenses((prev) => {
          const next = new Map(prev);
          docs.forEach((doc) => {
            if (doc._table === 'expenses') {
              next.delete(doc._id);
            }
          });
          return next;
        });
      },
      onConflict: (conflicts) => {
        // Auto-resolve: pick latest by timestamp
        conflicts.forEach(async (conflict) => {
          const latest = [conflict.winner, ...conflict.losers]
            .sort((a, b) => b.timestamp - a.timestamp)[0];
          await encryptedStore.resolveConflict(conflict.table, conflict.id, latest);
        });
      }
    });

    encryptedStore.loadAll();
    setStore(encryptedStore);

    return () => {
      encryptedStore.disconnectRemote();
    };
  }, [dbName, password]);

  return { expenses: Array.from(expenses.values()), store };
}

function App() {
  const { expenses, store } = useEncryptedStore('myapp', 'my-password');

  const addExpense = async () => {
    await store?.put('expenses', {
      _id: crypto.randomUUID(),
      amount: 25,
      description: 'Coffee',
      timestamp: Date.now()
    });
  };

  return (
    <div>
      <button onClick={addExpense}>Add Expense</button>
      <ul>
        {expenses.map((exp) => (
          <li key={exp._id}>{exp.description}: ${exp.amount}</li>
        ))}
      </ul>
    </div>
  );
}

TypeScript Types

interface Doc {
  _id: string;
  _table: string;
  [key: string]: any;
}

interface ConflictInfo {
  docId: string;
  table: string;
  id: string;
  currentRev: string;
  conflictRevs: string[];
  winner: Doc;
  losers: Doc[];
}

interface SyncInfo {
  direction: 'push' | 'pull' | 'both';
  change: {
    docs_read?: number;
    docs_written?: number;
    doc_write_failures?: number;
    errors?: any[];
  };
}

interface DecryptionErrorEvent {
  docId: string;
  error: Error;
  rawDoc: any;
}

interface RemoteOptions {
  url: string;
  live?: boolean;
  retry?: boolean;
}

interface EncryptedStoreOptions {
  passphraseMode?: "derive" | "raw";
}

How It Works

  1. Encryption: Documents are encrypted with AES-256-GCM before storage
  2. Storage: Encrypted data stored in PouchDB (IndexedDB in browser, LevelDB in Node)
  3. Change Detection: PouchDB's changes feed notifies of all changes
  4. Conflict Detection: PouchDB's MVCC detects conflicts automatically
  5. Sync: Bi-directional sync with CouchDB using PouchDB replication
  6. Events: Callbacks notify your app of changes, conflicts, and sync progress

Browser vs Node.js

Browser (Vite/Webpack)

  • Use pouchdb-browser@^8.0.1 - includes IndexedDB adapter
  • Smaller bundle size
  • Works with Vite, Webpack, etc.
  • Vite users:
    • Add define: { global: 'globalThis' } to vite.config.ts (PouchDB v8 requirement)
    • Install events package: npm install events (fixes "Class extends" errors)
  • Import: Use const PouchDB = PouchDBModule.default || PouchDBModule for better compatibility

Node.js

  • Use pouchdb - includes LevelDB adapter
  • For CLI tools, servers, etc.

Security Notes

  • Encryption happens client-side before any data leaves the device
  • Remote servers only see encrypted blobs
  • Password is never transmitted or stored
  • Use a strong password (consider using a key derivation function like PBKDF2)

License

MIT

Why PouchDB?

  • Mature: 10+ years of production use
  • Reliable: Battle-tested conflict resolution
  • Compatible: Works with any CouchDB server
  • Offline-first: Built for unreliable networks
  • Simple: Easy to understand replication model
  • Free: No vendor lock-in, self-hostable