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

idb-activerecord

v1.2.0

Published

ActiveRecord-style API for IndexedDB with TypeScript support

Readme

IDB ActiveRecord

A modern, type-safe ActiveRecord-style API for IndexedDB in JavaScript and TypeScript.

Overview

IDB ActiveRecord provides a clean, intuitive interface for working with IndexedDB, abstracting away the complexity of the native IndexedDB API while maintaining its power and performance. Inspired by Ruby's ActiveRecord pattern, this library makes browser-based data persistence simple and elegant.

Features

  • ActiveRecord Pattern: Model-based API with familiar CRUD operations
  • TypeScript Support: Full type safety with generics and interfaces
  • Promise-based: Modern async/await API
  • Query Builder: Chainable query methods for complex data retrieval
  • Relationships: Support for hasOne, hasMany, and belongsTo associations
  • Migrations: TableBuilder for schema definition with automatic object store creation
  • Transactions: Automatic transaction management with beginTransaction for manual control
  • Callbacks: beforeCreate, afterCreate, beforeUpdate, afterUpdate, beforeDestroy, afterDestroy
  • Validation: Built-in validation rules for presence, length, and format
  • Sync Adapters: Pluggable sync with REST APIs and cloud databases (Turso & SQLite adapters included)
  • Lightweight: Minimal dependencies, small bundle size (~35-40KB minified)
  • Browser Support: Works in all modern browsers with IndexedDB support

Installation

npm / yarn / pnpm

npm install idb-activerecord
# or
yarn add idb-activerecord
# or
pnpm add idb-activerecord

CDN (via jsDelivr)

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/idb-activerecord.min.js"></script>

All exports are available under the global IDBActiveRecord object:

<script>
  const { Database, ActiveRecord } = IDBActiveRecord;
</script>

Quick Start

With npm (TypeScript / ESM)

import { ActiveRecord, Database } from 'idb-activerecord';

// Define your model
interface User {
  id?: number;
  name: string;
  email: string;
  age: number;
}

// Create a model class with a declared schema
class User extends ActiveRecord<User> {
  static tableName = 'users';

  static columns = {
    name:  { type: 'string',  nullable: false },
    email: { type: 'string',  nullable: false },
    age:   { type: 'integer', default: 0 }
  };
}

// Initialize the database
const db = new Database('my-app');
db.registerModel(User);
await db.connect();

// Create a record
const user = await User.create({
  name: 'John Doe',
  email: '[email protected]',
  age: 30
});

// Find a record
const foundUser = await User.find(1);

// Update a record
await user.update({ age: 31 });

// Delete a record
await user.destroy();

// Query with conditions
const adults = await User.where('age', '>=', 18).all();

With CDN (plain HTML)

<!DOCTYPE html>
<html>
<head>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/idb-activerecord.min.js"></script>
</head>
<body>
  <p>To view the database: open your devTools > Application > IndexedDB > my-app > users</p>
  <ul id="user-list"></ul>
  <button id="clear-btn">Clear Database</button>

  <script>
    const { ActiveRecord, Database } = IDBActiveRecord;

    class User extends ActiveRecord {
      static tableName = 'users';

      static columns = {
        name:  { type: 'string',  nullable: false },
        email: { type: 'string',  nullable: false },
        age:   { type: 'integer', default: 0 }
      };
    }

    const db = new Database('my-app');
    db.registerModel(User);

    async function renderUsers() {
      const users = await User.where('age', '>=', 18).all();
      const list = document.getElementById('user-list');
      list.innerHTML = '';
      users.forEach(user => {
        const li = document.createElement('li');
        li.textContent = `${user.name} (${user.email}) — age ${user.age}`;
        list.appendChild(li);
      });
    }

    db.connect().then(async () => {
      await User.create({ name: 'John Doe', email: '[email protected]', age: 30 });
      await User.create({ name: 'Jane Smith', email: '[email protected]', age: 25 });

      await renderUsers();

      document.getElementById('clear-btn').addEventListener('click', async () => {
        const all = await User.all();
        for (const user of all) {
          await user.destroy();
        }
        await renderUsers();
      });
    });
  </script>
</body>
</html>

Defining Models

A model is a class that extends ActiveRecord with a tableName and a columns declaration. columns is the single source of truth for the model's schema — it's used to provision remote tables when syncing, document what fields the model has, and (in future versions) drive type checking.

class Task extends ActiveRecord<Task> {
  static tableName = 'tasks';

  static columns = {
    title:    { type: 'string',  nullable: false },
    status:   { type: 'string',  default: 'pending' },
    priority: { type: 'integer', default: 0 },
    done:     { type: 'boolean', default: false }
  };
}

Column options

Each entry under columns accepts these optional fields:

| Field | Type | Default | Description | |-------|------|---------|-------------| | type | 'string' \| 'integer' \| 'boolean' \| 'datetime' | 'string' | Field's data type | | nullable | boolean | true | Whether null/undefined is allowed | | default | unknown | — | Default value when not provided | | primaryKey | boolean | false | Marks the field as the primary key | | autoIncrement | boolean | false | Auto-incrementing integer (for primary keys) |

You don't need to declare id — it's added automatically as an auto-incrementing primary key.

Schema-less mode

If you omit static columns, the model is schema-less — any field you put on a record is stored as-is, and (when syncing) columns are inferred from records at sync time. This is convenient for prototyping but means fresh clients can't sync the schema until at least one record exists locally, and there's no protection against typos in field names. Declaring columns is recommended for any real app.

API Reference

Database

import { Database } from 'idb-activerecord';

const db = new Database(name: string, version?: number);
await db.connect();
db.registerModel(ModelClass);
await db.close();

Model CRUD Operations

Create

const user = await User.create({
  name: 'John Doe',
  email: '[email protected]'
});

Read

// Find by ID
const user = await User.find(1);

// Find all
const users = await User.all();

// Find first matching
const user = await User.where('name', 'John').first();

// Find with multiple conditions
const users = await User.where('age', '>=', 18)
  .where('name', '!=', 'Admin')
  .all();

Update

// Update a specific record
await user.update({ age: 31 });

// Update multiple records
await User.where('status', 'active').update({ lastSeen: Date.now() });

Delete

// Delete a specific record
await user.destroy();

// Delete multiple records
await User.where('age', '<', 18).destroyAll();

Query Builder

// Chaining conditions
const users = await User.where('age', '>=', 18)
  .where('name', 'like', 'John%')
  .orderBy('name', 'asc')
  .limit(10)
  .all();

// Count records
const count = await User.where('age', '>=', 18).count();

// Check existence
const exists = await User.where('email', '[email protected]').exists();

Relationships

class Post extends ActiveRecord<Post> {
  static tableName = 'posts';
  
  // Define relationships
  static belongsTo = {
    author: User
  };
}

class User extends ActiveRecord<User> {
  static tableName = 'users';
  
  static hasMany = {
    posts: Post
  };
  
  static hasOne = {
    profile: Profile
  };
}

// Access relationships
const user = await User.find(1);
const posts = await user.hasMany('posts'); // Returns user's posts
const profile = await user.hasOne('profile'); // Returns user's profile

const post = await Post.find(1);
const author = await post.belongsTo('author'); // Returns post's author

Schema management

Schema is handled automatically. Register your models before calling connect() and Database creates any missing object stores and indexes, bumping the IndexedDB version as needed:

const db = new Database('my-app');
db.registerModel(User);
db.registerModel(Post);
await db.connect();  // creates 'users' and 'posts' stores if they don't exist

Adding indexes — define static indexes on your model and they are created automatically on the first connect():

class User extends ActiveRecord {
  static tableName = 'users';

  static indexes = [
    { name: 'email_index', keyPath: 'email', unique: true },
    { name: 'age_index',   keyPath: 'age' }
  ];
}

Adding a new model later — just register it and reconnect. Database probes the existing schema, detects the missing store, and runs an upgrade automatically:

// v1: only User existed
const db = new Database('my-app');
db.registerModel(User);
await db.connect();

// Later — add Post without touching a version number
db.registerModel(Post);
await db.connect();  // detects missing 'posts' store, upgrades transparently

Sync stores (__sync_meta, __sync_changes) are also created automatically the first time db.sync() is called or a sync-enabled model is registered — no extra setup required.

Migrations are automatic — the Migration class is exported for advanced use but most apps won't need it directly.

Transactions

// Automatic transaction in CRUD operations
await User.transaction(async () => {
  const user = await User.create({ name: 'John' });
  await Post.create({ title: 'Hello', userId: user.id });
});

// Begin a manual transaction
const tx = await User.beginTransaction();
// Use the transaction for operations (implementation dependent)

Advanced Usage

Custom Indexes

class User extends ActiveRecord<User> {
  static tableName = 'users';
  
  static indexes = [
    { name: 'email_index', keyPath: 'email', unique: true },
    { name: 'age_index', keyPath: 'age' }
  ];
}

Scopes

class User extends ActiveRecord<User> {
  static tableName = 'users';
  
  static adults() {
    return this.where('age', '>=', 18);
  }
  
  static recent() {
    return this.orderBy('createdAt', 'desc').limit(10);
  }
}

// Use scopes
const recentAdults = await User.adults().recent().all();

Callbacks

class User extends ActiveRecord<User> {
  static tableName = 'users';
  
  static beforeCreate = (record) => {
    record.createdAt = new Date();
  };
  
  static afterUpdate = (record) => {
    console.log('User updated:', record);
  };
}

Validation

class User extends ActiveRecord<User> {
  static tableName = 'users';
  
  static validates = {
    name: { presence: true, length: { minimum: 2 } },
    email: { presence: true, format: /@/ }
  };
}

const user = Object.create(User.prototype);
Object.assign(user, { name: '' });
const valid = await user.isValid();
if (!valid) {
  console.log(user.errors);
}

Multi-User Sync

For multi-user / multi-device scenarios, idb-activerecord handles change tracking, soft deletes, and version-based conflict resolution automatically.

Basic usage

Enable sync on your model, connect an adapter, call db.sync():

import { Database, ActiveRecord, TursoAdapter, ConflictStrategy } from 'idb-activerecord';

class Task extends ActiveRecord {
  static tableName = 'tasks';
  static enableSync = true;  // auto-tracks updatedAt, _version, change log
  static softDelete = true;  // destroy() sets _deletedAt instead of removing the row

  // Declared schema — strict source of truth for sync (see "Defining columns")
  static columns = {
    title:  { type: 'string', nullable: false },
    status: { type: 'string', default: 'pending' }
  };
}

const db = new Database('my-app');  // version auto-managed
db.registerModel(Task);
await db.connect();

const adapter = new TursoAdapter();
await adapter.connect({ url: 'https://api.example.com', endpointPattern: '/{table}' });

// Bidirectional sync: push pending changes → pull remote → merge
const result = await db.sync('tasks', adapter, {
  strategy: ConflictStrategy.LAST_WRITE_WINS,
  onProgress: (msg) => console.log(msg)
});

console.log(`Pushed ${result.pushed}, pulled ${result.pulled}, conflicts ${result.conflicts}`);

Auto-sync

For most apps you don't need to call db.sync() manually. db.enableAutoSync(adapter, options) schedules a debounced sync after every CUD operation on any sync-enabled model, with optional periodic polling for remote updates:

await adapter.connect({ url: 'https://api.example.com' });

db.enableAutoSync(adapter, {
  debounceMs: 400,        // coalesce bursts of writes
  pollIntervalMs: 5000,   // pull remote changes every 5s (omit to disable polling)
  onSync: (table, result) => console.log(`${table} synced`, result),
  onError: (table, err) => console.error(table, err)
});

// Now any CUD on a model with `static enableSync = true` triggers a sync.
await Task.create({ title: 'Buy milk' });  // sync fires ~400ms later

// To stop:
db.disableAutoSync();

Sync work is deferred via requestIdleCallback (with a 1s timeout fallback), so it doesn't compete with rendering. Models without static enableSync = true are ignored.

Soft-deleted records

const active  = await Task.all();           // excludes deleted records
const deleted = await Task.onlyDeleted();   // only deleted records
const all     = await Task.withDeleted();   // everything

await Task.restore(id);                     // undo a soft delete

Advanced usage — direct SyncEngine access

db.sync() is a convenience wrapper. For lower-level control — inspecting the change log, resetting sync cursors, or composing custom sync flows — access the engine directly:

import { SyncEngine } from 'idb-activerecord';

// db.getSyncEngine() returns the shared engine wired to the database
const engine = db.getSyncEngine();

// Or create and wire your own
const engine = new SyncEngine();
engine.setDatabase(db.getDB());

// Inspect pending changes before pushing
const count = await engine.getPendingCount('tasks');

// Push and pull as separate steps
await engine.pushChanges('tasks', adapter);
const remote = await engine.pullChanges('tasks', adapter);
await engine.mergeChanges('tasks', remote, adapter, { strategy: ConflictStrategy.LOCAL_WINS });

// Reset sync state for a table (forces full re-pull on next sync)
await engine.clearSyncData('tasks');

How it works

  • Change tracking — every create/update/destroy on a sync-enabled model appends to an internal __sync_changes store
  • Version stamps — each record gets _version (integer) and updatedAt fields, incremented on every write
  • Soft deletesdestroy() sets _deletedAt so deletions propagate as tombstones to other devices
  • Cursor tracking__sync_meta persists lastPullAt per table so pulls only fetch what changed since last sync
  • Conflict resolution — newer _version wins; ties fall back to updatedAt; or use ConflictStrategy.LOCAL_WINS / REMOTE_WINS / CUSTOM

See examples/sqlite-sync for a runnable multi-user demo with a SQLite backend.

Adapters

Most apps should use db.sync() or db.enableAutoSync() — they handle change tracking, version stamping, soft deletes, schema provisioning, and conflict resolution. The adapter API documented here is the lower-level building block, useful when you need to bypass the SyncEngine, build a custom adapter, or integrate with a non-standard backend.

Built-in adapters

| Adapter | Description | Status | |---------|-------------|--------| | TursoAdapter | Turso / libSQL / SQLite (direct client or HTTP mode) | ✅ Ready | | SQLiteAdapter | Node.js node:sqlite (DatabaseSync or HTTP mode) | ✅ Ready |

TursoAdapter

TursoAdapter syncs to a Turso/libSQL/SQLite database. It supports two modes:

Direct client mode (server-side): Pass a raw @libsql/client instance — the adapter handles shimming internally:

import { createClient } from '@libsql/client';
import { Database, ActiveRecord } from 'idb-activerecord';
import { TursoAdapter } from 'idb-activerecord/turso-adapter';

class Task extends ActiveRecord {
  static tableName = 'tasks';
  static enableSync = true;
  static columns = {
    title:  { type: 'string', nullable: false },
    status: { type: 'string', default: 'pending' }
  };
}

const db = new Database('my-app');
db.registerModel(Task);
await db.connect();

// Direct client mode (server-side)
const client = createClient({ url: 'libsql://my-db.turso.io', authToken: '...' });
const adapter = new TursoAdapter();
await adapter.connect({ client });

db.enableAutoSync(adapter, { debounceMs: 500, pollIntervalMs: 5000 });

HTTP mode (browser-side): Use url/endpointPattern to talk to a SyncServer instance:

import { Database, ActiveRecord, TursoAdapter } from 'idb-activerecord';

const db = new Database('my-app');
db.registerModel(Task);
await db.connect();

// HTTP mode (browser-side) - talks to SyncServer
const adapter = new TursoAdapter();
await adapter.connect({
  url: 'http://localhost:3002',
  endpointPattern: '/{table}'
});

db.enableAutoSync(adapter, { debounceMs: 500, pollIntervalMs: 5000 });

For custom clients (e.g. @tursodatabase/database, better-sqlite3), implement the TursoClient interface (prepare(sql).run/all) and pass it to connect(). See the adapter source for the interface definition.

The adapter handles CREATE TABLE IF NOT EXISTS provisioning, ALTER TABLE ADD COLUMN for new fields, version-based optimistic concurrency, and tombstone propagation via the deleted_at column. It maps the SyncEngine wire fields _version / _deletedAt to the SQL columns version / deleted_at.

SQLiteAdapter

SQLiteAdapter syncs to a SQLite database. It supports two modes:

Direct client mode (server-side): Use Node.js's built-in node:sqlite module (DatabaseSync):

import { DatabaseSync } from 'node:sqlite';
import { SQLiteAdapter } from 'idb-activerecord/sqlite-adapter';

const db = new DatabaseSync('app.db');
const adapter = new SQLiteAdapter();
await adapter.connect({ client: db });

HTTP mode (browser-side): Use url/endpointPattern to talk to a SyncServer instance:

import { SQLiteAdapter } from 'idb-activerecord';

const adapter = new SQLiteAdapter();
await adapter.connect({
  url: 'http://localhost:3001',
  endpointPattern: '/{table}'
});

Same feature set as TursoAdapter: CREATE TABLE IF NOT EXISTS, ALTER TABLE ADD COLUMN, version-based optimistic concurrency, and tombstone propagation.

Sync Server

SyncServer is a ready-to-use HTTP server for sync adapters. It provides REST endpoints for schema operations, pull/push, and soft deletes. Adapter-agnostic — works with any sync adapter.

Note: SyncServer is Node.js-only and must be imported directly:

import { createClient } from '@libsql/client';
import { TursoAdapter } from 'idb-activerecord/turso-adapter';
import { SyncServer } from 'idb-activerecord/sync-server';

const client = createClient({ url: 'libsql://my-db.turso.io', authToken });
const adapter = new TursoAdapter();
await adapter.connect({ client });

const server = new SyncServer({
  port: 3002,
  adapter,
  // Optional: customize routes
  routes: {
    health: async (req, res) => {
      // Custom health check
    }
  }
});

await server.init();

Endpoints:

  • GET /health — health check
  • GET /schema/:table — get table schema
  • POST /schema — create/alter table
  • GET /:table — pull records (supports since, owner_id, include_deleted query params)
  • POST /:table — push records
  • DELETE /:table/:id — soft delete
  • POST /migrations — migrations (no-op by default)

Low-level usage

If you need to call an adapter directly (instead of going through SyncEngine):

import { TursoAdapter } from 'idb-activerecord';

const adapter = new TursoAdapter();
await adapter.connect({
  url: 'https://api.example.com',
  endpointPattern: '/{table}'
});

// Provision the remote table (idempotent — SyncEngine calls this for you)
await adapter.ensureTable('tasks', Task.getColumnDefs() ?? []);

// Pull remote changes since a cursor
const remoteTasks = await adapter.pull<Task>({ table: 'tasks', since: lastSync });

// Push ActiveRecord instances (must have a `tableName` — plain objects need `options.table`)
const result = await adapter.push([task1, task2]);

// Per-record conflict resolution
const winner = await adapter.resolveConflict(localTask, remoteTask, ConflictStrategy.LAST_WRITE_WINS);

Note that calling adapter.push() directly bypasses the SyncEngine — no change-log entries are consumed, no _version is incremented, and no updatedAt is stamped. Use db.sync() if you want those guarantees.

Building a custom adapter

Extend BaseAdapter and implement the abstract methods:

import {
  BaseAdapter,
  AdapterConfig,
  SyncQuery,
  PushOptions,
  SyncResult,
  TableSchema,
  SyncMigration,
  ColumnDef,
  ActiveRecord
} from 'idb-activerecord';

class MyAdapter extends BaseAdapter {
  async connect(config: AdapterConfig): Promise<void> {
    this.config = config;
    this.connected = true;
  }

  async disconnect(): Promise<void> {
    this.connected = false;
  }

  async pull<T extends ActiveRecord>(query: SyncQuery): Promise<T[]> {
    // Fetch records from your backend. Honor query.since (cursor),
    // query.where (filters), and query.includeDeleted (tombstones).
    return [];
  }

  async push<T extends ActiveRecord>(records: T[], options?: PushOptions): Promise<SyncResult> {
    // Send records to your backend. Use options?.table when records are plain objects.
    return { pushed: records.length, pulled: 0, conflicts: 0, errors: [], timestamp: new Date() };
  }

  async ensureTable(table: string, columns?: ColumnDef[]): Promise<void> {
    // Create or migrate the remote table to match `columns`.
    // SyncEngine calls this before every push/pull cycle.
  }

  async getRemoteSchema(table: string): Promise<TableSchema> {
    return { name: table, columns: [], indexes: [] };
  }

  async applyMigration(migration: SyncMigration): Promise<void> {
    // Optional — forward migration intent to your backend.
  }
}

Browser Support

  • Chrome 24+
  • Firefox 16+
  • Safari 10+
  • Edge 12+
  • Opera 15+

Contributing

Contributions are welcome! Feel free to open a PR or issue.

License

ISC

Author

Vann Ek

See Also