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

@aikofy/client-db

v1.0.1

Published

IndexedDB sync wrapper with HLC, conflict resolution, and WebRTC gossip sync

Readme

@aikofy/client-db

A TypeScript-first, offline-ready, peer-to-peer syncing database for web browsers. Built on IndexedDB with a clean storage abstraction, Hybrid Logical Clock (HLC) ordering, pluggable conflict resolution, and WebRTC gossip sync — no server required for data replication.

npm version license


Features

  • Offline-first — writes always succeed locally; sync happens automatically on reconnect
  • Real-time peer sync — every local write is pushed to all connected peers instantly over WebRTC data channels
  • Room isolation — peers only discover and sync with peers sharing the same DB name; one DB per user is safe
  • Peer-to-peer — gossip protocol over WebRTC, no central database server needed
  • Hybrid Logical Clock (HLC) — causal ordering of writes across nodes without relying on wall-clock agreement
  • Smart bootstrap — new peers auto-detect their state and pick the right sync strategy (snapshot, delta, or full merge)
  • Pluggable conflict resolution — Last-Write-Wins (default), First-Write-Wins, or a custom resolver per collection
  • Change originonChange callbacks receive origin: 'local' | 'peer' so you can distinguish your own writes from incoming sync changes
  • Soft deletes — tombstones preserve sync integrity; deleted records are never lost
  • Delta sync — only changes since last sync are exchanged, not full datasets
  • Snapshot bootstrap — new peers receive a full snapshot then switch to delta sync automatically
  • Fully typed — strict TypeScript with generics; collection access is type-safe
  • Framework-agnostic — plain TypeScript, works in React, Vue, Svelte, or vanilla JS
  • Tree-shakeable — ESM + CJS dual build via tsup

Installation

npm install @aikofy/client-db
# or
bun add @aikofy/client-db
# or
pnpm add @aikofy/client-db

Peer requirements: Modern browsers only (Chrome 89+, Firefox 86+, Safari 15+). No IE support.


Quick Start

import { createDB } from '@aikofy/client-db';

const db = await createDB({
  name: 'myapp',
  version: 1,
  collections: {
    todos: {
      indexes: ['status', ['userId', 'createdAt']],
      conflictStrategy: 'lww', // last-write-wins (default)
    },
  },
});

// Write
await db.todos.put({ _id: 'todo-1', title: 'Buy milk', status: 'open' });

// Read
const todo = await db.todos.get('todo-1');

// Query
const openTodos = await db.todos.query({
  where: { status: 'open' },
  orderBy: '_updatedAt',
  limit: 20,
});

// Delete (soft — tombstone, syncs to peers)
await db.todos.delete('todo-1');

// React to local writes and incoming sync changes
const unsubscribe = db.todos.onChange((changes) => {
  for (const change of changes) {
    console.log(change.origin); // 'local' | 'peer'
    console.log(change.operation); // 'put' | 'delete'
  }
});

// Export / import snapshots
const snapshot = await db.export();
await db.import(snapshot);

// Clean up
await db.close();

Sync (WebRTC P2P)

Pass a sync config to enable peer-to-peer sync via WebRTC. You need a signaling server (WebSocket) to help peers discover each other — only handshake metadata is exchanged over it, never your data.

The companion package @aikofy/client-db-sync is the ready-made signaling server for this library.

Room isolation

Peers are automatically grouped by DB name. Only clients with the same name in their createDB config can discover and sync with each other. This makes it safe to use a userID as the DB name — each user's data stays completely isolated.

Development (no auth)

Start the signaling server with AUTH_DISABLED=true and connect without a token:

const db = await createDB({
  name: 'myapp',          // peers with name 'myapp' form one group
  version: 1,
  collections: {
    notes: { indexes: ['authorId'] },
  },
  sync: {
    signalingServer: 'ws://localhost:8080/signal',
    iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
  },
});

Production (JWT auth)

When auth is enabled on the signaling server, your backend issues a token with subject set to the DB name (typically the userID). The library appends the room and token automatically:

// 1. Your backend issues a token scoped to this user's DB name
const { token } = await fetch('https://signal.example.com/token', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-admin-secret': process.env.SIGNAL_ADMIN_SECRET,
  },
  body: JSON.stringify({ ttl: '24h', subject: currentUser.id }),
}).then(r => r.json());

// 2. Use the userID as the DB name — the library ties the room to it automatically
const db = await createDB({
  name: currentUser.id,   // DB name = room = token subject
  version: 1,
  collections: {
    notes: { indexes: ['authorId'] },
  },
  sync: {
    signalingServer: `wss://signal.example.com/signal?token=${token}`,
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' },
      // Add TURN servers here for reliable NAT traversal in production
    ],
  },
});

The signaling server validates that the token's subject matches the DB name. A token for user-123 can only join room user-123 — knowing a userID is not enough to access another user's sync group.

console.log(db.syncStatus); // 'online' | 'offline' | 'syncing'

What happens after connection

Once connected, the library:

  1. Exchanges peer-hello messages with each connected peer (advertises current HLC watermark)
  2. Picks the right bootstrap strategy based on local state (see below)
  3. Pushes every local write to all connected peers immediately via RTCDataChannel (~20–100ms)
  4. Re-gossips every 30 seconds as a catch-up heartbeat
  5. On window.online, syncs with all connected peers to collect any offline writes

Note: The signaling server is only used for WebRTC handshake (offer/answer/ICE candidates). After connection, all data flows directly peer-to-peer.


Bootstrap Strategies

When a client connects to peers for the first time in a session, it automatically picks the right strategy:

Case 1 — Pre-loaded snapshot (initialSnapshot)

You passed initialSnapshot to sync. The library awaits the promise (e.g. a Drive download), imports the snapshot into an empty DB, then connects to peers. After connecting it syncs bidirectionally with all peers from the snapshot's HLC watermark, catching any writes that happened since the snapshot was taken.

If the promise rejects or resolves to null, the library skips the import and falls through to Case 2.

Case 2 — Completely new client (empty DB)

The library waits briefly (300ms) to collect peer-hello responses from all initially-connected peers, then picks the most up-to-date peer (highest HLC) and requests their full snapshot. After applying it, it delta-syncs with all remaining peers to catch anything that peer didn't have.

Case 3 — Returning client (offline data)

The client has existing local data from a previous session. On reconnect, it syncs bidirectionally with all connected peers — not just a random subset — so every peer's offline writes are collected without loss.


Cloud Snapshot Seeding

Use initialSnapshot to seed a new browser from a cloud-stored snapshot (Google Drive, S3, etc.) before connecting to any peers. This avoids a slow full-snapshot bootstrap from a peer.

// Download snapshot from your cloud storage
async function loadSnapshotFromDrive(): Promise<Snapshot | null> {
  try {
    const res = await fetch('https://storage.example.com/snapshots/user-123.json');
    if (!res.ok) return null;
    return res.json() as Promise<Snapshot>;
  } catch {
    return null;
  }
}

const db = await createDB({
  name: currentUser.id,
  version: 1,
  collections: { todos: { indexes: ['status'] } },
  sync: {
    signalingServer: `wss://signal.example.com/signal?token=${token}`,
    iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
    // Peers are not contacted until this promise settles
    initialSnapshot: loadSnapshotFromDrive(),
  },
});

What happens:

| Scenario | Result | |----------|--------| | Drive download succeeds | Snapshot imported → peers connect → delta sync from snapshot watermark (fast) | | Drive download fails / returns null | No import → peers connect → full snapshot fetched from best peer (Case 2) | | DB already has local data | initialSnapshot is ignored (won't overwrite existing data) |

To keep the cloud snapshot fresh, call db.export() and upload the result periodically or on window.beforeunload.

// Save snapshot to your cloud storage on page unload
window.addEventListener('beforeunload', async () => {
  const snapshot = await db.export();
  navigator.sendBeacon(
    'https://storage.example.com/snapshots/user-123.json',
    JSON.stringify(snapshot),
  );
});

API Reference

createDB(config)

const db = await createDB(config: DBConfig);

Returns a TypedDB<C> — an object where each key of collections is a CollectionProxy, plus export, import, syncStatus, and close.

DBConfig

| Field | Type | Description | |-------|------|-------------| | name | string | IndexedDB database name. Also used as the sync room — peers with the same name sync together. | | version | number | Schema version — increment to trigger migrations | | collections | Record<string, CollectionSchema> | Collection definitions | | sync | SyncConfig (optional) | Enable WebRTC sync |

CollectionSchema

| Field | Type | Description | |-------|------|-------------| | indexes | (string \| string[])[] (optional) | Single-field or compound indexes | | conflictStrategy | 'lww' \| 'first-write-wins' \| resolver (optional) | Defaults to 'lww' |

SyncConfig

| Field | Type | Description | |-------|------|-------------| | signalingServer | string | WebSocket URL. Append ?token=<jwt> when auth is enabled. The room param is appended automatically from DBConfig.name. | | iceServers | IceServer[] | STUN/TURN servers for WebRTC | | nodeId | string (optional) | Override auto-generated node UUID | | initialSnapshot | Snapshot \| Promise<Snapshot \| null \| undefined> (optional) | Seed the DB before connecting to peers. Peer connections are held until this settles. See Cloud snapshot seeding. |


CollectionProxy<T>

All collection operations are async and available on db.<collectionName>.

put(doc)

const saved = await db.todos.put({ _id: 'abc', title: 'Hello', status: 'open' });
// _id is auto-generated (UUID) if omitted

Upserts a record. Stamps _rev (HLC), _updatedAt (HLC), _deleted: false. Returns the full Doc<T>. The write is immediately pushed to all connected peers.

get(id)

const todo = await db.todos.get('abc'); // Doc<T> | null

query(options?)

const results = await db.todos.query({
  where: { status: 'open' },   // field equality filters
  orderBy: '_updatedAt',        // any field
  orderDir: 'desc',             // 'asc' (default) | 'desc'
  limit: 10,
  offset: 0,
  includeDeleted: false,        // default false — excludes tombstones
});

delete(id)

Soft delete — sets _deleted: true with a new HLC revision. The record remains in IndexedDB and is synced as a deletion event to peers immediately.

onChange(callback)

const unsubscribe = db.todos.onChange((changes: ChangeEntry[]) => {
  // fires on local writes AND incoming sync changes from peers
  for (const change of changes) {
    console.log(change.origin);    // 'local' | 'peer'
    console.log(change.operation); // 'put' | 'delete'
    console.log(change.id);        // document _id
  }
});

unsubscribe(); // stop listening

System Fields

Every stored document has these read-only system fields:

| Field | Type | Description | |-------|------|-------------| | _id | string | Primary key | | _rev | HLCTimestamp | HLC-based revision string | | _deleted | boolean | Tombstone flag | | _updatedAt | HLCTimestamp | Last write timestamp (HLC) |

HLCTimestamp strings are lexicographically sortable — alphabetical order equals causal order.


ChangeEntry

Passed to onChange callbacks:

| Field | Type | Description | |-------|------|-------------| | id | string | Document _id | | collection | string | Collection name | | _rev | HLCTimestamp | Revision at time of change | | _updatedAt | HLCTimestamp | Timestamp of change | | operation | 'put' \| 'delete' | Type of write | | origin | 'local' \| 'peer' | Who made the change |


Conflict Resolution

Built-in strategies

// Last-Write-Wins: highest HLC timestamp wins (default)
conflictStrategy: 'lww'

// First-Write-Wins: lowest HLC timestamp wins
conflictStrategy: 'first-write-wins'

Custom resolver

conflictStrategy: (local, remote) => {
  // Merge fields, pick a winner, or return any Doc shape
  return { ...remote, score: local.score + remote.score };
}

All conflicts (both versions + resolved) are logged to an internal _conflicts collection for audit.


Snapshot API

// Export full snapshot (all collections + HLC watermark)
const snapshot = await db.export();

// Import snapshot — use before connecting sync to seed a new client
await db.import(snapshot);

Large snapshots (>256 KB) are automatically chunked over the WebRTC data channel.


Advanced: Direct HLC Access

import { HLC, parseHLC } from '@aikofy/client-db';

const hlc = new HLC('my-node-id');
const ts = hlc.tick();          // generate next timestamp
hlc.update(remoteTimestamp);    // advance clock from a remote event
const { physicalMs, counter, nodeId } = parseHLC(ts);

Advanced: Custom Storage Adapter

The IStorageAdapter interface is the abstraction layer. Implement it to swap in any backend (e.g., SQLite for React Native):

import type { IStorageAdapter } from '@aikofy/client-db';

class MySQLiteAdapter implements IStorageAdapter {
  async put(collection, doc) { /* ... */ }
  async get(collection, id) { /* ... */ }
  async query(collection, options) { /* ... */ }
  async delete(collection, id) { /* ... */ }
  async bulkInsert(collection, docs) { /* ... */ }
  async changes(since) { /* ... */ }
  async export() { /* ... */ }
  async import(snapshot) { /* ... */ }
  async collectionNames() { /* ... */ }
  async close() { /* ... */ }
}

All sync, HLC, and conflict resolution logic is adapter-agnostic and reusable as-is.


React Example

import { useEffect, useState } from 'react';
import { createDB } from '@aikofy/client-db';
import type { TypedDB } from '@aikofy/client-db';

const collections = {
  todos: { indexes: ['status'], conflictStrategy: 'lww' as const },
};

// Fetch a short-lived token from your own backend (which calls @aikofy/client-db-sync's POST /token).
// The token's subject must equal the DB name (userId).
// In dev, skip this and use AUTH_DISABLED=true on the signaling server.
async function fetchSignalToken(userId: string): Promise<string> {
  const res = await fetch('/api/signal-token', { method: 'POST' });
  const { token } = await res.json() as { token: string };
  return token;
}

let dbInstance: TypedDB<typeof collections> | null = null;

async function getDB(userId: string) {
  if (!dbInstance) {
    const isDev = import.meta.env.DEV; // Vite / any bundler dev flag
    const signalingServer = isDev
      ? 'ws://localhost:8080/signal'
      : `wss://signal.example.com/signal?token=${await fetchSignalToken(userId)}`;

    dbInstance = await createDB({
      name: userId,   // DB name = room — only this user's peers sync together
      version: 1,
      collections,
      sync: {
        signalingServer,
        iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
      },
    });
  }
  return dbInstance;
}

function useTodos(userId: string) {
  const [todos, setTodos] = useState([]);

  useEffect(() => {
    let unsubscribe: (() => void) | undefined;

    getDB(userId).then((db) => {
      const load = () => db.todos.query({ orderBy: '_updatedAt', orderDir: 'desc' }).then(setTodos);
      load();
      unsubscribe = db.todos.onChange(() => load());
    });

    return () => unsubscribe?.();
  }, [userId]);

  return todos;
}

Publishing Checklist (for maintainers)

Before publishing to npm:

  1. Bump version in package.json
  2. bun run build — verify dist/ is clean
  3. bun run test — all tests pass
  4. bun run typecheck — no type errors
  5. npm publish --access public (or bun publish)

Project Structure

src/
  core/
    types.ts          # IStorageAdapter interface + all shared types
    hlc.ts            # Hybrid Logical Clock implementation
    conflict.ts       # LWW, FWW, and custom conflict resolution
    change-log.ts     # Change log helpers
  storage/
    schema.ts         # Collection schema builder
    indexeddb.ts      # IndexedDB implementation of IStorageAdapter
  sync/
    webrtc-transport.ts  # WebRTC + WebSocket signaling (room-aware)
    gossip.ts            # Gossip sync protocol (K=3 fanout, 30s interval, real-time push)
    snapshot.ts          # Snapshot export / import / chunking
  db.ts               # createDB() factory + CollectionProxy
  index.ts            # Public exports

Dependencies

| Package | Why | |---------|-----| | idb | Promise-based IndexedDB wrapper | | uuid | Node ID generation |

No paywalled or proprietary dependencies.


Contributing

Contributions are welcome! Please open an issue before submitting a PR for large changes.

bun install
bun run test        # run tests
bun run test:watch  # watch mode
bun run typecheck   # type check
bun run build       # build dist/

License

MIT © Aikofy