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

skibbadb

v0.2.0

Published

A developer-friendly, embeddable NoSQL database layer on top of SQLite with full TypeScript support

Readme

skibbaDB

skibbaDB is a small TypeScript document database built on top of SQLite.

You write normal JavaScript objects. skibbaDB stores them in SQLite, validates them with Zod, gives every document an id, and lets you query them without writing SQL for common work.

import { z } from 'zod/v3';
import { skibba } from 'skibbadb';

const db = skibba(':memory:');

const users = db.collection('users', z.object({
    name: z.string(),
    email: z.string().email(),
    age: z.number().int().optional(),
}), {
    unique: ['email'],
    index: ['age'],
});

const ada = await users.insert({
    name: 'Ada',
    email: '[email protected]',
    age: 36,
});

const found = await users.get(ada.id);
const adults = await users.where('age').gte(18).orderBy('name').all();

await users.update(ada.id, { age: 37 });
await db.close();

Why use it?

  • Easy document storage: save objects like { name, email, age }.
  • SQLite underneath: your data lives in a real SQLite database.
  • Zod validation: bad data is rejected before it is saved.
  • TypeScript-friendly: schemas become useful TypeScript types.
  • Simple queries: use where, orderBy, limit, first, all, and count.
  • Async by default: normal methods return promises. Sync methods are grouped under .sync.
  • Indexes and constraints: add unique fields, indexes, foreign keys, and advanced SQLite constraints.
  • Runs in Bun and Node.js: the driver is detected automatically.

Install

bun add skibbadb zod

or:

npm install skibbadb zod

Important: import Zod from zod/v3.

import { z } from 'zod/v3';

The package you install is still zod. The /v3 import is a compatibility path that skibbaDB expects.

The basic idea

A database has collections. A collection is like a table for one kind of object.

const db = skibba('app.db');

const posts = db.collection('posts', z.object({
    title: z.string(),
    body: z.string(),
    published: z.boolean().default(false),
}));

Every document gets a public id.

const post = await posts.insert({
    title: 'Hello',
    body: 'My first post',
});

console.log(post.id);

Internally, SQLite stores that value as _id. In normal app code, use id.

Create a database

Use an in-memory database for tests and examples:

const db = skibba();
const db = skibba(':memory:');

Use a file for real local data:

const db = skibba('app.db');
const db = skibba({ path: 'app.db', preset: 'local' });

Close the database when your app is done with it:

await db.close();

createDB() is still available as an older compatibility name, but skibba() is the recommended entry point.

Create a collection

const userSchema = z.object({
    name: z.string(),
    email: z.string().email(),
    age: z.number().int().optional(),
});

const users = db.collection('users', userSchema);

The schema controls what can be saved. This insert works:

await users.insert({
    name: 'Grace',
    email: '[email protected]',
});

This insert fails because the email is not valid:

await users.insert({
    name: 'Grace',
    email: 'not an email',
});

Add indexes and rules

Use friendly collection options for common SQLite rules:

const users = db.collection('users', userSchema, {
    unique: ['email'],
    index: ['age'],
});

Common options:

| Option | What it does | |--------|--------------| | unique: ['email'] | No two documents can have the same email. | | index: ['age'] | Makes age queries faster. | | index: ['profile.city'] | Indexes a nested field. | | references: { authorId: 'users.id' } | Adds a foreign key. | | id: 'customId' | Uses a different public id field name. |

Example with a relationship:

const posts = db.collection('posts', z.object({
    title: z.string(),
    authorId: z.string(),
}), {
    references: {
        authorId: 'users.id',
    },
});

For lower-level SQLite control, use advanced.constrainedFields:

const products = db.collection('products', z.object({
    sku: z.string(),
    price: z.number(),
}), {
    advanced: {
        constrainedFields: {
            sku: { unique: true, nullable: false },
            price: {
                type: 'REAL',
                checkConstraint: 'price >= 0',
            },
        },
    },
});

CRUD

CRUD means create, read, update, and delete.

const user = await users.insert({
    name: 'Ada',
    email: '[email protected]',
});

const sameUser = await users.get(user.id);
const allUsers = await users.all();
const firstUser = await users.first();
const totalUsers = await users.count();

const updated = await users.update(user.id, {
    name: 'Ada Lovelace',
});

await users.remove(user.id);

get(id) returns null when no document exists.

update(id, patch) changes only the fields you pass.

upsert(id, doc) means "update this document if it exists, otherwise insert it."

await users.upsert('user-1', {
    name: 'Linus',
    email: '[email protected]',
});

Queries

Start with where(field), choose a comparison, then run the query with all(), first(), or count().

const adults = await users.where('age').gte(18).all();
const ada = await users.where('email').eq('[email protected]').first();
const adultCount = await users.where('age').gte(18).count();

Sorting and paging:

const page1 = await users
    .where('age').gte(18)
    .orderBy('name', 'asc')
    .limit(10)
    .all();

const page2 = await users
    .orderBy('name')
    .page(2, 10)
    .all();

Common comparisons:

await users.where('age').eq(36).all();
await users.where('age').neq(36).all();
await users.where('age').gt(18).all();
await users.where('age').gte(18).all();
await users.where('age').lt(65).all();
await users.where('age').lte(65).all();
await users.where('age').between(18, 65).all();
await users.where('name').in(['Ada', 'Grace']).all();
await users.where('name').nin(['Deleted']).all();

String helpers:

await users.where('email').contains('@example.com').all();
await users.where('name').startsWith('A').all();
await users.where('name').endsWith('a').all();
await users.where('name').like('A%').all();
await users.where('name').ilike('a%').all();

Field existence:

await users.where('age').exists().all();
await users.where('age').notExists().all();

Nested fields use dot paths:

const people = db.collection('people', z.object({
    name: z.string(),
    profile: z.object({
        city: z.string(),
    }),
}));

await people.where('profile.city').eq('Chicago').all();

Arrays:

const articles = db.collection('articles', z.object({
    title: z.string(),
    tags: z.array(z.string()),
}));

await articles.where('tags').arrayContains('typescript').all();
await articles.where('tags').arrayLength('gte', 2).all();

OR queries:

const results = await users
    .where('name').eq('Ada')
    .or((q) => q.where('email').eq('[email protected]'))
    .all();

You can also start a query with query():

const results = await users
    .query()
    .where('age').gte(18)
    .orderBy('name')
    .all();

Select, aggregate, and join

Most apps only need the query examples above. These helpers are available when you need more SQL-like queries.

Select only some fields:

const names = await users
    .query()
    .select('name', 'email')
    .all();

Aggregate:

const rows = await users
    .query()
    .avg('age', 'averageAge')
    .all();

Group and filter groups:

const rows = await users
    .query()
    .select('role')
    .count('*', 'total')
    .groupBy('role')
    .having('total').gt(1)
    .all();

Join collections:

const rows = await posts
    .query()
    .join('users', 'authorId', '_id')
    .all();

Join conditions use stored field names. When joining to a document id, use _id in the join condition.

Bulk operations

Use bulk when you want to write many documents at once.

const inserted = await users.bulk.insert([
    { name: 'Ada', email: '[email protected]' },
    { name: 'Grace', email: '[email protected]' },
]);

await users.bulk.update([
    { _id: inserted[0].id, doc: { name: 'Ada Lovelace' } },
]);

await users.bulk.upsert([
    {
        _id: 'user-3',
        doc: { name: 'Linus', email: '[email protected]' },
    },
]);

await users.bulk.delete([inserted[1].id]);

Note: the current bulk update and bulk upsert input objects use _id as the property name. Pass the same value you normally read from doc.id.

Atomic updates

Atomic updates change a document in one database operation.

const posts = db.collection('posts', z.object({
    title: z.string(),
    views: z.number().default(0),
    tags: z.array(z.string()).default([]),
    status: z.string().default('draft'),
}));

const post = await posts.insert({ title: 'Hello' });

await posts.atomic.update(post.id, {
    $inc: { views: 1 },
});

await posts.atomic.update(post.id, {
    $set: { status: 'published' },
    $push: { tags: 'typescript' },
});

Supported operators:

| Operator | What it does | |----------|--------------| | $inc | Adds to a number. | | $set | Sets a field to a new value. | | $push | Adds one value to an array. |

Transactions

A transaction groups work together. If one step fails, the whole group rolls back.

await db.transaction(async () => {
    const user = await users.insert({
        name: 'Ada',
        email: '[email protected]',
    });

    await posts.insert({
        title: 'Hello',
        authorId: user.id,
    });
});

Sync API

The main API is async. Always use await with async methods.

Synchronous methods are available under .sync:

const user = users.sync.insert({
    name: 'Ada',
    email: '[email protected]',
});

const found = users.sync.get(user.id);
const all = users.sync.all();
const total = users.sync.count();

users.sync.update(user.id, { name: 'Ada Lovelace' });
users.sync.remove(user.id);

db.sync.exec('PRAGMA journal_mode = WAL');
const rows = db.sync.query('SELECT 1 AS ok');
db.sync.close();

Do not use sync methods with plugins unless you create the database with allowSyncWithPlugins: true. Shared connections also require async methods.

Plugins

Plugins can run code around inserts, updates, deletes, queries, transactions, and database lifecycle events.

import {
    skibba,
    TimestampPlugin,
    AuditLogPlugin,
    CachePlugin,
    MetricsPlugin,
} from 'skibbadb';

const db = skibba('app.db');

db.use(new TimestampPlugin());
db.use(new AuditLogPlugin());
db.use(new CachePlugin({ ttl: 60_000 }));
db.use(new MetricsPlugin());

Built-in plugins exported by skibbaDB:

| Plugin | Purpose | |--------|---------| | TimestampPlugin | Adds or updates timestamp fields. | | AuditLogPlugin | Logs database operations. | | CachePlugin | Provides simple document/query cache helpers. | | MetricsPlugin | Tracks operation counts and timings. | | ValidationPlugin | Adds custom validation rules. |

Vector search

Vector search is available through collection.vector.search().

You need the optional sqlite-vec package and a vector field configured with type: 'VECTOR'.

npm install sqlite-vec
const docs = db.collection('docs', z.object({
    title: z.string(),
    embedding: z.array(z.number()),
}), {
    advanced: {
        constrainedFields: {
            embedding: {
                type: 'VECTOR',
                vectorDimensions: 3,
            },
        },
    },
});

await docs.insert({
    title: 'Intro',
    embedding: [0.1, 0.2, 0.3],
});

const matches = await docs.vector.search({
    field: 'embedding',
    vector: [0.1, 0.2, 0.25],
    limit: 5,
    distance: 'cosine',
});

console.log(matches[0].document);
console.log(matches[0].distance);

If the vector extension is not available, skibbaDB warns and vector features are disabled for that field.

Index maintenance and diagnostics

Rebuild or check indexes:

const rebuild = await users.indexes.rebuild();
const check = await users.indexes.check();

Check database health:

const health = await db.health();

console.log(health.ok);
console.log(health.collections);
console.log(health.warnings);

Explain a query:

const plan = await users
    .where('email').eq('[email protected]')
    .explain();

console.log(plan.sql);
console.log(plan.params);
console.log(plan.usesIndex);

Drivers and runtimes

skibbaDB chooses a driver automatically.

In Bun, it uses Bun's built-in SQLite support.

In Node.js, it uses the Node driver. The package includes better-sqlite3; LibSQL/Turso support is available through the optional @libsql/client package.

Install optional LibSQL support when you need it:

npm install @libsql/client

Examples:

const local = skibba({ driver: 'node', path: './data.db' });

const libsqlFile = skibba({
    driver: 'node',
    path: 'file:./data.db',
    libsql: true,
});

const turso = skibba({
    driver: 'node',
    path: 'libsql://your-db.turso.io',
    authToken: process.env.TURSO_AUTH_TOKEN,
});

Useful presets:

skibba({ preset: 'memory' });
skibba({ preset: 'local', path: 'app.db' });
skibba({ preset: 'test' });
skibba({ preset: 'server', path: 'server.db' });
skibba({ preset: 'turso', path: 'libsql://your-db.turso.io', authToken: '...' });

Errors

skibbaDB exports error classes so you can catch specific problems.

import {
    ValidationError,
    UniqueConstraintError,
    NotFoundError,
} from 'skibbadb';

try {
    await users.insert({ name: 'Ada', email: '[email protected]' });
    await users.insert({ name: 'Other Ada', email: '[email protected]' });
} catch (error) {
    if (error instanceof UniqueConstraintError) {
        console.log('That email is already used.');
    }

    if (error instanceof ValidationError) {
        console.log('The document does not match the schema.');
    }

    if (error instanceof NotFoundError) {
        console.log('The document was not found.');
    }
}

Exported error classes:

  • ValidationError
  • UniqueConstraintError
  • CheckConstraintError
  • NotFoundError
  • DatabaseError
  • PluginError
  • PluginTimeoutError
  • VersionMismatchError
  • CollectionExistsError
  • CollectionNotFoundError

Public API

The recommended collection methods are:

await collection.insert(doc);
await collection.get(id);
await collection.update(id, patch);
await collection.upsert(id, doc);
await collection.remove(id);
await collection.all();
await collection.count();
await collection.first();
collection.where(field);
collection.query();

Grouped APIs:

collection.bulk.insert(docs);
collection.bulk.update(updates);
collection.bulk.upsert(items);
collection.bulk.delete(ids);

collection.sync.insert(doc);
collection.sync.get(id);
collection.sync.update(id, patch);
collection.sync.upsert(id, doc);
collection.sync.remove(id);
collection.sync.all();
collection.sync.count();
collection.sync.first();

collection.atomic.update(id, operators);
collection.indexes.rebuild();
collection.indexes.check();
collection.vector.search(options);

db.sync.exec(sql);
db.sync.query(sql);
db.sync.close();

Compatibility aliases

Older method names still work. New code should use the names on the right.

| Older name | Preferred name | |------------|----------------| | createDB() | skibba() | | findById(id) | get(id) | | put(id, patch) | update(id, patch) | | delete(id) | remove(id) | | toArray() | all() | | find() | all() | | insertBulk(docs) | bulk.insert(docs) | | putBulk(updates) | bulk.update(updates) | | upsertBulk(items) | bulk.upsert(items) | | deleteBulk(ids) | bulk.delete(ids) | | atomicUpdate(id, ops) | atomic.update(id, ops) | | rebuildIndexes() | indexes.rebuild() | | vectorSearch(opts) | vector.search(opts) | | insertSync(doc) | sync.insert(doc) | | findByIdSync(id) | sync.get(id) | | putSync(id, patch) | sync.update(id, patch) | | deleteSync(id) | sync.remove(id) | | toArraySync() | sync.all() |

These aliases are kept for compatibility. Do not remove them from existing apps until you have updated your code.

Common mistakes

Forgetting await

Most methods are async.

const user = await users.get(id);

Importing the wrong Zod path

Use this:

import { z } from 'zod/v3';

Using _id in normal app code

Use id:

const user = await users.insert({ name: 'Ada', email: '[email protected]' });
await users.get(user.id);

Only some older and bulk input shapes still use _id as a property name.

Registering the same collection twice

This creates a collection:

const users = db.collection('users', userSchema);

This gets an already registered collection:

const sameUsers = db.collection('users');

Calling db.collection('users', userSchema) twice throws CollectionExistsError.

Development

bun install
bun test
npm run build

License

MIT