nanodb-orm
v0.0.5
Published
Lightweight ORM wrapper for Drizzle with auto-migrations, schema introspection, and CLI
Maintainers
Readme
nanodb-orm
A lightweight ORM wrapper for Drizzle ORM with automatic migrations, schema introspection, CLI tools, and support for SQLite/Turso databases.
Features
- TypeScript First — Full type inference from schema to queries
- Auto-Migrations — Automatically creates and updates database schemas from Drizzle tables
- Schema Introspection — Comprehensive schema analysis and validation
- Multi-Database — Works with local SQLite and remote Turso databases
- Transactions — Full transaction support with automatic rollback
- CLI Tools — Built-in commands including Drizzle Studio integration
- Plugin System — Extensible with hooks for audit, validation, transformations
- Minimal — ~1K lines of code, zero bloat
Installation
npm install nanodb-orm
# For Drizzle Studio support (optional)
npm install drizzle-kit --save-devCLI
nanodb-orm includes a CLI for common database operations:
# Launch Drizzle Studio (visual database browser)
npx nanodb studio
# With custom port
npx nanodb studio --port 3000
# With specific database file
npx nanodb studio --db ./data/myapp.db
# Other commands
npx nanodb setup # Initialize schema and seed data
npx nanodb reset # Drop all tables and recreate
npx nanodb status # Show database health and stats
npx nanodb validate # Validate schema against database
npx nanodb help # Show all commandsDrizzle Studio
Launch a visual database browser at https://local.drizzle.studio:
npx nanodb studio
Import Styles
// Default import (recommended)
import nanodb from 'nanodb-orm';
const users = nanodb.schema.table('users', { ... });
const db = await nanodb.createDatabase({ tables: { users } });
await db.select().from(users).where(nanodb.query.eq(users.id, 1));// Named imports
import { createDatabase, schema, query } from 'nanodb-orm';// Individual imports (tree-shakeable)
import { createDatabase, table, integer, text, eq } from 'nanodb-orm';Quick Start
1. Define Your Schema
import nanodb from 'nanodb-orm';
const users = nanodb.schema.table('users', {
id: nanodb.schema.integer('id').primaryKey({ autoIncrement: true }),
name: nanodb.schema.text('name').notNull(),
email: nanodb.schema.text('email').unique().notNull(),
age: nanodb.schema.integer('age'),
});
const posts = nanodb.schema.table('posts', {
id: nanodb.schema.integer('id').primaryKey({ autoIncrement: true }),
title: nanodb.schema.text('title').notNull(),
userId: nanodb.schema.integer('userId').notNull(),
});2. Create Database
const db = await nanodb.createDatabase({
tables: { users, posts },
seedData: {
users: [{ name: 'Alice', email: '[email protected]', age: 28 }],
},
});
export { db };3. Query Your Data
// SELECT
const allUsers = await db.select().from(users);
const adults = await db.select().from(users).where(nanodb.query.gte(users.age, 18));
// INSERT
await db.insert(users).values({ name: 'Bob', email: '[email protected]' });
// UPDATE
await db.update(users).set({ name: 'Robert' }).where(nanodb.query.eq(users.email, '[email protected]'));
// DELETE
await db.delete(users).where(nanodb.query.eq(users.email, '[email protected]'));Type Inference
nanodb-orm provides full type inference from your schema:
import {
createDatabase,
table,
integer,
text,
type SelectModel,
type InsertModel,
} from 'nanodb-orm';
const users = table('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
name: text('name').notNull(),
email: text('email').notNull(),
age: integer('age'),
});
// Infer types directly from your table definitions
type User = SelectModel<typeof users>;
// { id: number; name: string; email: string; age: number | null }
type NewUser = InsertModel<typeof users>;
// { id?: number; name: string; email: string; age?: number | null }
// The database is fully typed
const db = await createDatabase({ tables: { users } });
// All operations are type-safe
const allUsers: User[] = await db.select().from(users);
// Seed data is type-checked at compile time
const db2 = await createDatabase({
tables: { users },
seedData: {
users: [
{ name: 'Alice', email: '[email protected]' }, // ✓ Valid
// { name: 123 }, // ✗ TypeScript error!
],
},
});Available Type Utilities
| Type | Description |
|------|-------------|
| SelectModel<T> | Infer the row type (SELECT result) from a table |
| InsertModel<T> | Infer the insert type from a table (optional auto-generated columns) |
| SchemaModels<S> | Extract all row types from a schema object |
| SchemaInsertModels<S> | Extract all insert types from a schema |
| NanoDatabase<S> | The typed database instance |
| Schema | Type for schema objects |
| AnyTable | Type constraint for Drizzle tables |
API Reference
createDatabase(config)
Create and initialize database. Returns db with all utilities attached.
const db = await createDatabase({
tables: { users, posts },
seedData: { users: [...] }, // Type-checked against schema
migrationConfig: {
preserveData: true, // default: true
autoMigrate: true, // default: true
dropTables: false, // default: false
},
plugins: [auditPlugin, validationPlugin], // optional
});Database Operations (from db)
// Health & Status
await db.healthCheck(); // { healthy, tables, totalRecords, ... }
await db.isReady(); // true/false
await db.sync(); // Sync with Turso (if remote)
// Reset & Seed
await db.reset(); // Drop all, recreate, reseed
await db.seed(); // Re-seed data
await db.clearData(); // Delete all data (keep tables)Schema Introspection (from db.schema)
db.schema.tables(); // ['users', 'posts'] - typed as (keyof Schema)[]
db.schema.getTable('users'); // { columns, primaryKey, indexes }
db.schema.getColumns('users'); // ['id', 'name', 'email']
await db.schema.validate(); // { isValid, missingTables, ... }
db.schema.stats(); // Full schema statistics
db.schema.relationships(); // Foreign key relationshipsMigrations (from db.migrations)
await db.migrations.run(); // Run pending migrations
await db.migrations.validate(); // Validate schema vs DB
await db.migrations.checkTables(); // { users: true, posts: true }transaction(fn) / batch(statements)
Execute operations atomically. Uses Drizzle's native transaction when available (better for Turso).
import nanodb from 'nanodb-orm';
// Transaction with custom logic
const result = await nanodb.transaction(async (tx) => {
await tx.run(nanodb.query.sql`INSERT INTO users (name) VALUES ('Alice')`);
await tx.run(nanodb.query.sql`INSERT INTO posts (title, userId) VALUES ('Hello', 1)`);
return { created: true };
});
if (result.success) {
console.log(result.result); // { created: true }
} else {
console.log('Rolled back:', result.error?.message);
}
// Batch multiple statements (simpler for bulk operations)
const batchResult = await nanodb.batch([
nanodb.query.sql`INSERT INTO users (name) VALUES ('Bob')`,
nanodb.query.sql`INSERT INTO users (name) VALUES ('Carol')`,
]);parseDbError(error, context)
Parse SQLite errors into user-friendly messages.
import { parseDbError } from 'nanodb-orm';
try {
await db.insert(users).values({ email: '[email protected]' });
} catch (error) {
const parsed = parseDbError(error, { table: 'users', operation: 'insert' });
console.log(parsed.message); // "Duplicate value for unique column 'email'"
}Plugins
Extend nanodb-orm with custom hooks that run automatically on database operations.
Plugin Interface
import { NanoPlugin } from 'nanodb-orm';
const myPlugin: NanoPlugin = {
name: 'my-plugin',
// Lifecycle
install: (db) => db, // Modify db instance
onReady: (db) => {}, // Called after createDatabase
onError: (err, op, table) => {}, // Called on hook errors
// Auto hooks (run automatically)
beforeInsert: (table, data) => data, // Transform data before insert
afterInsert: (table, data, result) => {},
beforeUpdate: (table, data) => data, // Transform data before update
afterUpdate: (table, data, result) => {},
beforeDelete: (table, condition) => condition,
afterDelete: (table, condition, result) => {},
// Query hooks (also auto-triggered)
beforeQuery: (table, fields) => fields,
afterQuery: (table, fields, result) => {},
};Example Plugins
These are example plugins you can create - nanodb-orm provides the plugin system, you build the plugins:
// Example: Audit logging with timing
const timers = new Map<string, number>();
const auditPlugin: NanoPlugin = {
name: 'audit',
beforeInsert: (table) => { timers.set('op', performance.now()); console.log(`INSERT ${table}`); },
afterInsert: () => { console.log(` ↳ ${(performance.now() - timers.get('op')!).toFixed(1)}ms`); },
beforeQuery: (table) => { timers.set('op', performance.now()); console.log(`SELECT ${table}`); },
afterQuery: (t, _, rows) => { console.log(` ↳ ${rows.length} rows in ${(performance.now() - timers.get('op')!).toFixed(1)}ms`); },
};
// Auto-generate slugs
const slugPlugin: NanoPlugin = {
name: 'slug',
beforeInsert: (table, data) => {
if (table === 'posts' && data.title && !data.slug) {
return { ...data, slug: data.title.toLowerCase().replace(/\s+/g, '-') };
}
return data;
},
};
// Validation
const validationPlugin: NanoPlugin = {
name: 'validation',
beforeInsert: (table, data) => {
if (table === 'users' && !data.email?.includes('@')) {
throw new Error('Invalid email format');
}
return data;
},
};
// Use plugins
const db = await createDatabase({
tables,
plugins: [auditPlugin, slugPlugin, validationPlugin],
});
// Hooks run automatically
await db.insert(posts).values({ title: 'My Post' }); // slug auto-generated
await db.insert(users).values({ email: 'invalid' }); // throws error
// Check loaded plugins
db.plugins.list(); // ['audit', 'slug', 'validation']Best Practices
Recommended Project Structure
db/
├── schema.ts # Table definitions
├── index.ts # Database instance export
├── types.ts # Type aliases (SelectModel, InsertModel)
├── plugins.ts # Custom plugins
└── seeds.ts # Seed data1. Schema Order Matters
Define parent tables before children for correct seeding order:
// db/schema.ts
import nanodb from 'nanodb-orm';
// Parent tables first (no foreign keys)
export const users = nanodb.schema.table('users', {
id: nanodb.schema.integer('id').primaryKey({ autoIncrement: true }),
name: nanodb.schema.text('name').notNull(),
email: nanodb.schema.text('email').notNull().unique(),
});
export const categories = nanodb.schema.table('categories', {
id: nanodb.schema.integer('id').primaryKey({ autoIncrement: true }),
name: nanodb.schema.text('name').notNull(),
});
// Child tables after (have foreign keys)
export const posts = nanodb.schema.table('posts', {
id: nanodb.schema.integer('id').primaryKey({ autoIncrement: true }),
title: nanodb.schema.text('title').notNull(),
userId: nanodb.schema.integer('userId').notNull(), // FK to users
categoryId: nanodb.schema.integer('categoryId'), // FK to categories
});
// Order: parents → children
export const schema = { users, categories, posts };2. Single Database Instance
// db/index.ts
import nanodb from 'nanodb-orm';
import { schema } from './schema';
import { seedData } from './seeds';
export const db = await nanodb.createDatabase({ tables: schema, seedData });// anywhere.ts
import { db } from './db';
import { users } from './db/schema';
const allUsers = await db.select().from(users);3. Use Type Inference
// db/types.ts
import { type SelectModel, type InsertModel } from 'nanodb-orm';
import { users, posts } from './schema';
export type User = SelectModel<typeof users>;
export type NewUser = InsertModel<typeof users>;
export type Post = SelectModel<typeof posts>;
// Usage
async function createUser(data: NewUser): Promise<void> {
await db.insert(users).values(data); // TypeScript enforces shape
}4. Prefer Grouped Imports
// ✅ Clean - default import
import nanodb from 'nanodb-orm';
const users = nanodb.schema.table('users', { ... });
await db.select().from(users).where(nanodb.query.eq(users.id, 1));
// ✅ Also good - grouped imports
import { schema, query, errors } from 'nanodb-orm';
// ❌ Avoid - many individual imports
import { table, integer, text, eq, gte, and, sql, count, ... } from 'nanodb-orm';5. Handle Errors Gracefully
import { parseDbError, DatabaseError } from 'nanodb-orm';
try {
await db.insert(users).values({ email: '[email protected]' });
} catch (error) {
if (error instanceof DatabaseError) {
// Already formatted with context
console.log(error.message); // "UNIQUE constraint failed: users.email"
console.log(error.table); // "users"
} else {
const parsed = parseDbError(error, { table: 'users' });
console.log(parsed.format());
}
}6. Use Transactions for Atomic Operations
import nanodb from 'nanodb-orm';
const result = await nanodb.transaction(async (tx) => {
await tx.run(nanodb.query.sql`INSERT INTO users (name) VALUES ('Alice')`);
await tx.run(nanodb.query.sql`INSERT INTO posts (title, userId) VALUES ('Hello', 1)`);
return { inserted: 2 };
});
if (!result.success) {
console.log('Rolled back:', result.error?.message);
}7. Validate on Startup (Production)
import nanodb from 'nanodb-orm';
const db = await nanodb.createDatabase({ tables: schema });
if (process.env.NODE_ENV === 'production') {
const validation = await db.schema.validate();
if (!validation.isValid) {
throw new Error(`Schema mismatch: ${validation.missingTables.join(', ')}`);
}
const health = await db.healthCheck();
if (!health.healthy) {
console.warn('Database issues:', health.errors);
}
}8. Keep Plugins Simple
// Good: focused, single responsibility
const timestampPlugin: NanoPlugin = {
name: 'timestamps',
beforeInsert: (_table, data) => ({
...data,
createdAt: new Date().toISOString(),
}),
beforeUpdate: (_table, data) => ({
...data,
updatedAt: new Date().toISOString(),
}),
};
// Avoid: complex business logic in hooks9. Environment Configuration
# .env
TURSO_CONNECTION_URL=libsql://your-db.turso.io
TURSO_AUTH_TOKEN=your-token
FORCE_LOCAL_DB=true # Use local SQLite
DATABASE_PATH=./data/app.db # Custom DB pathnanodb-orm auto-detects the right database:
- Turso: when
TURSO_*vars are set - Local: when
FORCE_LOCAL_DB=trueor no Turso config - Test: isolated
test.dbwhenNODE_ENV=test
Configuration
Environment Variables
# Remote Turso database
TURSO_CONNECTION_URL=libsql://your-db.turso.io
TURSO_AUTH_TOKEN=your-token
# Force local SQLite
FORCE_LOCAL_DB=true
# Custom database path (works with FORCE_LOCAL_DB or as fallback)
DATABASE_PATH=./data/myapp.dbDatabase Selection
- Turso — Used when
TURSO_CONNECTION_URLandTURSO_AUTH_TOKENare set - Local SQLite — Used when
FORCE_LOCAL_DB=trueor Turso credentials missing - Custom Path — Set
DATABASE_PATH=./path/to/db.sqlitefor custom location - Test Mode — Isolated
test.dbwhenNODE_ENV=test
Error Handling
Errors are automatically parsed into user-friendly messages:
import { DatabaseError, SchemaError, SeedError, parseDbError } from 'nanodb-orm';
try {
await DatabaseSync.setup();
} catch (error) {
if (error instanceof DatabaseError) {
console.log(error.message); // User-friendly message
console.log(error.operation); // 'seed', 'migration', etc.
console.log(error.table); // Table name if applicable
console.log(error.detail); // Additional context
}
}Error output is clean and actionable:
┌─ nanodb-orm error ─────────────────────────────
│ Column "email" does not exist
│ Table: users
│ Operation: seed
│ Detail: Row data: {"name":"Alice","email":"[email protected]"}
└────────────────────────────────────────────────Exports
// Default export (recommended)
import nanodb from 'nanodb-orm';
nanodb.createDatabase // Main entry point
nanodb.transaction // Atomic operations
nanodb.schema // .table, .integer, .text, .real, .blob
nanodb.query // .eq, .gte, .and, .or, .sql, .count, ...
nanodb.errors // .DatabaseError, .parse
nanodb.cli // .studio, .setup, .reset, .status, .validate
// Types (named imports)
import { type SelectModel, type InsertModel, type NanoPlugin } from 'nanodb-orm';nanodb-orm vs Drizzle + Turso (Direct)
| Feature | nanodb-orm | Drizzle + Turso |
|---------|------------|-----------------|
| Setup | One-liner: createDatabase({ tables }) | Manual: create client, drizzle, manage connection |
| Migrations | Automatic on startup | Manual: drizzle-kit push/migrate |
| Seeding | Built-in with seedData | Write seed scripts |
| Type Safety | ✅ Full (same as Drizzle) | ✅ Full |
| Query API | ✅ Same as Drizzle | ✅ Native Drizzle |
| Plugins/Hooks | ✅ beforeInsert, afterQuery, etc. | ❌ None |
| Schema Introspection | ✅ db.schema.tables() | ❌ Manual |
| Health Checks | ✅ db.healthCheck() | ❌ Manual |
| CLI | npx nanodb studio/status/validate | npx drizzle-kit studio only |
| Error Parsing | User-friendly messages | Raw SQLite errors |
| Connection | Auto-detects Turso vs local | Manual configuration |
When to Use What
| Use Case | Recommendation | |----------|----------------| | Quick prototyping | nanodb-orm | | Need plugins/hooks | nanodb-orm | | Want auto-migrations | nanodb-orm | | New SQLite/Turso project | nanodb-orm | | Maximum control | Drizzle directly | | Complex migration strategies | Drizzle + drizzle-kit | | Existing Drizzle project | Keep Drizzle |
Comparison
// nanodb-orm: 5 lines
import nanodb from 'nanodb-orm';
const users = nanodb.schema.table('users', { id: nanodb.schema.integer('id').primaryKey() });
const db = await nanodb.createDatabase({ tables: { users }, seedData: { users: [{ id: 1 }] } });
// Ready - tables created, seeded// Drizzle + Turso: More setup
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
import { sqliteTable, integer } from 'drizzle-orm/sqlite-core';
import { migrate } from 'drizzle-orm/libsql/migrator';
const users = sqliteTable('users', { id: integer('id').primaryKey() });
const client = createClient({ url: process.env.TURSO_CONNECTION_URL!, authToken: process.env.TURSO_AUTH_TOKEN! });
const db = drizzle(client);
await migrate(db, { migrationsFolder: './drizzle' });
await db.insert(users).values([{ id: 1 }]);nanodb-orm is a convenience layer — it uses Drizzle under the hood and passes through all queries unchanged. You get Drizzle's full type safety plus automatic setup, plugins, and utilities.
License
MIT © Damilola Alao
