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: 'ada@example.com',
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();- 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, andcount. - 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.
bun add skibbadb zodor:
npm install skibbadb zodImportant: 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.
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.
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.
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: 'grace@example.com',
});This insert fails because the email is not valid:
await users.insert({
name: 'Grace',
email: 'not an email',
});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 means create, read, update, and delete.
const user = await users.insert({
name: 'Ada',
email: 'ada@example.com',
});
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: 'linus@example.com',
});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('ada@example.com').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('grace@example.com'))
.all();You can also start a query with query():
const results = await users
.query()
.where('age').gte(18)
.orderBy('name')
.all();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.
Use bulk when you want to write many documents at once.
const inserted = await users.bulk.insert([
{ name: 'Ada', email: 'ada@example.com' },
{ name: 'Grace', email: 'grace@example.com' },
]);
await users.bulk.update([
{ _id: inserted[0].id, doc: { name: 'Ada Lovelace' } },
]);
await users.bulk.upsert([
{
_id: 'user-3',
doc: { name: 'Linus', email: 'linus@example.com' },
},
]);
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 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. |
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: 'ada@example.com',
});
await posts.insert({
title: 'Hello',
authorId: user.id,
});
});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: 'ada@example.com',
});
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 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 is available through collection.vector.search().
You need the optional sqlite-vec package and a vector field configured with type: 'VECTOR'.
npm install sqlite-vecconst 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.
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('ada@example.com')
.explain();
console.log(plan.sql);
console.log(plan.params);
console.log(plan.usesIndex);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/clientExamples:
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: '...' });skibbaDB exports error classes so you can catch specific problems.
import {
ValidationError,
UniqueConstraintError,
NotFoundError,
} from 'skibbadb';
try {
await users.insert({ name: 'Ada', email: 'ada@example.com' });
await users.insert({ name: 'Other Ada', email: 'ada@example.com' });
} 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:
ValidationErrorUniqueConstraintErrorCheckConstraintErrorNotFoundErrorDatabaseErrorPluginErrorPluginTimeoutErrorVersionMismatchErrorCollectionExistsErrorCollectionNotFoundError
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();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.
Most methods are async.
const user = await users.get(id);Use this:
import { z } from 'zod/v3';Use id:
const user = await users.insert({ name: 'Ada', email: 'ada@example.com' });
await users.get(user.id);Only some older and bulk input shapes still use _id as a property name.
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.
bun install
bun test
npm run buildMIT