
Security News
Attackers Are Hunting High-Impact Node.js Maintainers in a Coordinated Social Engineering Campaign
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.
litedbmodel
Advanced tools
A lightweight TypeScript ORM for PostgreSQL, MySQL, and SQLite with Active Record pattern
litedbmodel is a lightweight, SQL-friendly TypeScript ORM for PostgreSQL, MySQL, and SQLite. It is designed for production systems where you care about predictable SQL, explicit performance control, and operational safety (replication lag, N+1, accidental full scans).
Most ORMs hide SQL behind abstractions that are hard to debug and hard to tune.
litedbmodel keeps SQL visible and controllable: generated queries are intentionally simple, and complex cases use real SQL via query() / execute().
PkeyResult only when needed.create/createMany, update/updateMany, delete) require an explicit transaction boundary.Model.column) so IDE rename/find-references work.[Column, value]), with a sql tagged template for operators ([sql\${Col} > ?`, value]`). An ESLint plugin catches mistakes TS cannot.See Design Philosophy for detailed comparison with query-centric ORMs.
Model.query() / DBModel.execute()findHardLimit / hasManyHardLimitSKIP pattern for optional fields/conditionsChoose litedbmodel if you:
litedbmodel may be a poor fit if you:
Non-goals are deliberate trade-offs to keep SQL predictable and operations safe. litedbmodel is intentionally not trying to be a βdo-everythingβ ORM.
npm install litedbmodel reflect-metadata
# Plus your database driver:
npm install pg # PostgreSQL
npm install mysql2 # MySQL
npm install better-sqlite3 # SQLite
litedbmodel-gen generates model column definitions from SQL DDL (schema.sql). Column definitions inside markers are auto-updated when the schema changes; hand-written code (relations, methods, exports) is preserved.
npm install -D litedbmodel-gen embedoc
npx embedoc init && npx litedbmodel-gen init
npx embedoc generate --datasource schema # scaffold model files
npx embedoc build # sync column definitions
import 'reflect-metadata';
import { DBModel, model, column } from 'litedbmodel';
// 1. Define model
@model('users')
class UserModel extends DBModel {
@column() id?: number;
@column() name?: string;
@column() email?: string;
@column() is_active?: boolean;
}
export const User = UserModel.asModel(); // Adds type-safe column references
// 2. Configure database
DBModel.setConfig({
host: 'localhost',
database: 'mydb',
user: 'user',
password: 'pass',
// driver: 'mysql', // for MySQL
// driver: 'sqlite', // for SQLite (use database: './data.db')
});
// 3. CRUD operations
await User.create([
[User.name, 'John'],
[User.email, 'john@example.com'],
]);
await User.update([[User.id, 1]], [[User.name, 'Jane']]);
await User.delete([[User.is_active, false]]);
// Read operations
const users = await User.find([[User.is_active, true]]);
const john = await User.findOne([[User.email, 'john@example.com']]);
// With returning: true β get PkeyResult for re-fetching
const created = await User.create([...], { returning: true });
const [newUser] = await User.findById(created);
The @model decorator accepts optional configuration for default behaviors:
@model('entries', {
order: () => Entry.created_at.desc(), // DEFAULT_ORDER
filter: () => [[Entry.is_deleted, false]], // FIND_FILTER (auto-applied)
select: 'id, title, created_at', // SELECT_COLUMN
updateTable: 'entries_writable', // UPDATE_TABLE_NAME (for views)
})
class EntryModel extends DBModel {
@column() id?: number;
@column() title?: string;
@column() created_at?: Date;
@column.boolean() is_deleted?: boolean;
}
export const Entry = EntryModel.asModel();
| Option | Type | Description |
|---|---|---|
order | () => OrderSpec | Default ORDER BY for find() |
filter | () => Conds | Auto-applied WHERE conditions |
select | string | Default SELECT columns |
updateTable | string | Table name for INSERT/UPDATE |
group | () => Column | string | Default GROUP BY |
Note: Options using model columns (
order,filter,group) require lazy evaluation() =>because the model isn't fully defined when the decorator runs.
Types are inferred from TypeScript property types:
@column() id?: number; // Number conversion
@column() name?: string; // No conversion
@column() is_active?: boolean; // Boolean conversion
@column() created_at?: Date; // DateTime conversion
@column() large_id?: bigint; // BigInt conversion
@column('db_column_name') prop?: string; // Custom column name (string shorthand)
@column({ columnName: 'db_col' }) prop?: string; // Custom column name (object form)
@column({ primaryKey: true }) id?: number; // Mark as primary key
@column({ primaryKey: true, columnName: 'user_id' }) id?: number; // Both options
| Option | Type | Description |
|---|---|---|
columnName | string | Database column name (defaults to property name) |
primaryKey | boolean | Mark as part of primary key (for getPkey()) |
Use explicit type decorators when auto-inference isn't sufficient:
@column.date() birth_date?: string; // Date only (YYYY-MM-DD string)
@column.datetime() updated_at?: Date; // DateTime with timezone
@column.boolean() is_active?: boolean; // Explicit boolean
@column.number() amount?: number; // Explicit number
@column.uuid() id?: string; // UUID with auto-casting (PostgreSQL)
@column.stringArray() tags?: string[]; // String array
@column.intArray() scores?: number[]; // Integer array
@column.numericArray() prices?: number[]; // Numeric/decimal array
@column.booleanArray() flags?: boolean[]; // Boolean array
@column.datetimeArray() dates?: Date[]; // DateTime array
@column.json<Settings>() settings?: Settings; // JSON with type
Databases distinguish between date-only and datetime columns. @column.date() returns a string (YYYY-MM-DD), not a Date object, because a calendar date has no timezone concept:
| DB Column Type | Decorator | TypeScript Type | Example |
|---|---|---|---|
TIMESTAMP / DATETIME | @column() or @column.datetime() | Date | created_at, updated_at |
DATE | @column.date() | string | birth_date, settlement_date |
β οΈ Important: @column() with Date type is treated as TIMESTAMP/DATETIME. For date-only columns (no time component), you must use @column.date() explicitly.
// β
Correct usage
@column() created_at?: Date; // TIMESTAMP column (datetime β Date)
@column.datetime() updated_at?: Date; // TIMESTAMP column (datetime β Date)
@column.date() settlement_date?: string; // DATE column (date only β 'YYYY-MM-DD')
// β Wrong: will cause type mismatch errors
@column() settlement_date?: Date; // Treated as TIMESTAMP, not DATE!
Serialization behavior (PostgreSQL):
@column() / @column.datetime() β ISO 8601 UTC: 2024-06-15T10:30:00.000Z@column.date() β date string: 2024-06-15formatLocalDate and formatUTCDate convert a Date object to a YYYY-MM-DD string. Use these when constructing date values for queries or conditions:
import { formatLocalDate, formatUTCDate } from 'litedbmodel';
// Format using local timezone (matches @column.date() behavior)
formatLocalDate(new Date()); // '2024-06-15' (in local timezone)
// Format using UTC
formatUTCDate(new Date()); // '2024-06-15' (in UTC)
// Typical use: query with today's local date
const today = formatLocalDate(new Date());
const meals = await Meal.find([Meal.date, today]);
Write operations can optionally return a PkeyResult object:
interface PkeyResult {
key: Column[]; // Key column(s) used to identify rows
values: unknown[][]; // 2D array of key values
}
// Single PK example
{ key: [User.id], values: [[1], [2], [3]] }
// Composite PK example
{ key: [TenantUser.tenant_id, TenantUser.id], values: [[1, 100], [1, 101]] }
Default behavior: returning: false β returns null for better performance.
With returning: true: Returns PkeyResult with affected primary keys.
Note:
PkeyResult.keyalways contains primary key column(s), regardless ofkeyColumnsused inupdateMany.
// Default: returns null (no RETURNING)
await User.create([
[User.name, 'John'],
[User.email, 'john@example.com'],
]);
// With returning: true β returns PkeyResult
const result = await User.create([
[User.name, 'John'],
[User.email, 'john@example.com'],
], { returning: true });
// result: { key: [User.id], values: [[1]] }
// Multiple records
const result = await User.createMany([
[[User.name, 'John'], [User.email, 'john@example.com']],
[[User.name, 'Jane'], [User.email, 'jane@example.com']],
], { returning: true });
// result: { key: [User.id], values: [[1], [2]] }
// Fetch created records if needed
const [user] = await User.findById(result);
// Default: returns null (no RETURNING)
await User.update(
[[User.status, 'pending']], // conditions
[[User.status, 'active']], // values
);
// With returning: true β returns PkeyResult
const result = await User.update(
[[User.status, 'pending']],
[[User.status, 'active']],
{ returning: true }
);
// result: { key: [User.id], values: [[1], [2], [3]] }
// Bulk update with different values per row
const result = await User.updateMany([
[[User.id, 1], [User.name, 'John'], [User.email, 'john@example.com']],
[[User.id, 2], [User.name, 'Jane'], [User.email, 'jane@example.com']],
], { keyColumns: [User.id], returning: true });
// result: { key: [User.id], values: [[1], [2]] }
// Fetch updated records if needed
const users = await User.findById(result);
Generated SQL for updateMany:
| Database | SQL |
|---|---|
| PostgreSQL | UPDATE ... FROM UNNEST($1::int[], $2::text[], ...) AS v(...) WHERE t.id = v.id |
| MySQL 8.0.19+ | UPDATE ... JOIN (VALUES ROW(?, ?, ?), ...) AS v(...) ON ... SET ... |
| SQLite 3.33+ | WITH v(...) AS (VALUES (...), ...) UPDATE ... FROM v WHERE ... |
// Default: returns null (no RETURNING)
await User.delete([[User.is_active, false]]);
// With returning: true β returns PkeyResult
const result = await User.delete([[User.is_active, false]], { returning: true });
// result: { key: [User.id], values: [[4], [5]] }
Fetch records by primary key. Accepts PkeyResult format for efficient batch loading:
// Single record
const [user] = await User.findById({ values: [[1]] });
// Multiple records
const users = await User.findById({ values: [[1], [2], [3]] });
// Composite PK
const [entry] = await TenantUser.findById({
values: [[1, 100]] // [tenant_id, id]
});
// Use with update/delete result
const result = await User.update(...);
const users = await User.findById(result);
Generated SQL:
| Database | Single PK | Composite PK |
|---|---|---|
| PostgreSQL | WHERE id = ANY($1::int[]) | WHERE (col1, col2) IN (SELECT * FROM UNNEST(...)) |
| MySQL | WHERE id IN (?, ?, ?) | JOIN (VALUES ROW(...), ...) AS v ON ... |
| SQLite | WHERE id IN (?, ?, ?) | WITH v AS (VALUES ...) ... JOIN v ON ... |
// Insert or ignore
await User.create(
[[User.name, 'John'], [User.email, 'john@example.com']],
{ onConflict: User.email, onConflictIgnore: true }
);
// Insert or update
await User.create(
[[User.name, 'John'], [User.email, 'john@example.com']],
{ onConflict: User.email, onConflictUpdate: [User.name] }
);
// Composite unique key
await UserPref.create(
[[UserPref.user_id, 1], [UserPref.key, 'theme'], [UserPref.value, 'dark']],
{ onConflict: [UserPref.user_id, UserPref.key], onConflictUpdate: [UserPref.value] }
);
| Aspect | Behavior |
|---|---|
| Order | Matches database RETURNING order (not guaranteed across DBs; findById(result) order is also unspecified) |
| update result | Contains PKs of matched rows (rows matching WHERE condition, regardless of whether values actually changed) |
| delete result | Contains PKs of deleted rows |
| Duplicates | No duplicates (each row appears once; MySQL pre-SELECT uses DISTINCT) |
| Empty result | { key: [...], values: [] } when no rows affected (not null) |
Note: For MySQL (no
RETURNING), whenreturning: true:
update/delete: Executes pre-SELECT (withDISTINCT) to get PKs, then executes the operation (2 queries in same transaction)updateMany: Executes update, then SELECT to get PKs of affected rows (2 queries in same transaction)- When
returning: false(default): Single query, returnsnull
createMany and updateMany do not auto-split large batches. Users are responsible for chunking:
// Recommended: chunk large batches (DB-dependent limits)
const BATCH_SIZE = 1000; // Adjust based on your DB and row size
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
const chunk = rows.slice(i, i + BATCH_SIZE);
await User.updateMany(chunk, { keyColumns: [User.id] });
}
| Database | Practical Limits |
|---|---|
| PostgreSQL | ~32,767 parameters per query |
| MySQL | max_allowed_packet (default 64MB), ~65,535 placeholders |
| SQLite | 999 variables (compile-time SQLITE_MAX_VARIABLE_NUMBER) |
| Requirement | Description |
|---|---|
| Must be unique | keyColumns must uniquely identify rows (primary key or unique constraint) |
| Must exist in rows | Every row must include all keyColumns |
| Non-key columns | Columns not in keyColumns become SET clause values |
// β
Valid: keyColumns is primary key
await User.updateMany([
[[User.id, 1], [User.name, 'John']],
], { keyColumns: [User.id] });
// β
Valid: keyColumns is unique constraint
await User.updateMany([
[[User.email, 'john@example.com'], [User.name, 'John']],
], { keyColumns: [User.email] }); // If email has UNIQUE constraint
// β Invalid: keyColumns missing from row
await User.updateMany([
[[User.name, 'John']], // Missing User.id!
], { keyColumns: [User.id] });
The simplest and most type-safe form. The value type is validated at compile time:
await User.find([
[User.status, 'active'], // status = 'active'
[User.is_active, true], // is_active = TRUE
]);
await User.find([[User.email, 'john@example.com']]);
sql Tagged Template (for operators, LIKE, BETWEEN, IS NULL, etc.)Use the sql tagged template for conditions that need operators. The sql tag extracts the Column reference, producing a type-safe fragment.
See sql Tagged Template Literal for full reference.
import { sql } from 'litedbmodel';
await User.find([
[sql`${User.age} > ?`, 18], // age > 18
[sql`${User.age} BETWEEN ? AND ?`, [18, 65]], // age BETWEEN 18 AND 65
[sql`${User.name} LIKE ?`, '%test%'], // name LIKE '%test%'
[sql`${User.status} IN (?)`, ['active', 'pending']], // status IN ('active', 'pending')
sql`${User.deleted_at} IS NULL`, // deleted_at IS NULL (no value needed)
]);
Values can also be embedded directly in the template. The sql tag automatically extracts them as parameterized values:
await User.find([
sql`${User.age} > ${18}`,
sql`${User.name} LIKE ${'%test%'}`,
sql`${User.deleted_at} IS NULL`,
]);
await User.find([
[User.is_active, true],
User.or(
[[User.role, 'admin']],
[[User.role, 'moderator']],
),
], { order: User.created_at.desc() });
ESLint Plugin: Use
litedbmodel/eslint-pluginto catch mistakes that TypeScript cannot:
- Wrong model columns (e.g.,
User.find([[Post.id, 1]]))- Hardcoded column names instead of
${Model.column}- Missing
declarekeyword for relation properties
IN/NOT IN and EXISTS/NOT EXISTS subqueries with composite key support.
Key pairs use the same format as relation decorators: [[parentCol, targetCol], ...]
import { parentRef } from 'litedbmodel';
// IN subquery - key pairs: [[parentCol, targetCol]]
await User.find([
User.inSubquery([[User.id, Order.user_id]], [
[Order.status, 'paid']
])
]);
// β WHERE users.id IN (SELECT orders.user_id FROM orders WHERE orders.status = 'paid')
// Composite key IN subquery
await User.find([
User.inSubquery([
[User.id, Order.user_id],
[User.group_id, Order.group_id],
], [[Order.status, 'active']])
]);
// β WHERE (users.id, users.group_id) IN (SELECT orders.user_id, orders.group_id FROM orders WHERE orders.status = 'active')
// NOT IN subquery
await User.find([
User.notInSubquery([[User.id, BannedUser.user_id]])
]);
// β WHERE users.id NOT IN (SELECT banned_users.user_id FROM banned_users)
// Correlated subquery with parentRef
await User.find([
User.inSubquery([[User.id, Order.user_id]], [
[Order.tenant_id, parentRef(User.tenant_id)],
[Order.status, 'completed']
])
]);
// β WHERE users.id IN (SELECT orders.user_id FROM orders WHERE orders.tenant_id = users.tenant_id AND orders.status = 'completed')
// EXISTS subquery (conditions determine target table)
await User.find([
[User.is_active, true],
User.exists([
[Order.user_id, parentRef(User.id)]
])
]);
// β WHERE is_active = TRUE AND EXISTS (SELECT 1 FROM orders WHERE orders.user_id = users.id)
// NOT EXISTS subquery
await User.find([
User.notExists([
[Complaint.user_id, parentRef(User.id)]
])
]);
// β WHERE NOT EXISTS (SELECT 1 FROM complaints WHERE complaints.user_id = users.id)
Conditional fields without if-statements:
import { SKIP } from 'litedbmodel';
// β Imperative
const updates = [];
if (body.name !== undefined) updates.push([User.name, body.name]);
if (body.email !== undefined) updates.push([User.email, body.email]);
await User.update([[User.id, id]], updates);
// β
Declarative with SKIP
await User.update([[User.id, id]], [
[User.name, body.name ?? SKIP],
[User.email, body.email ?? SKIP],
[User.updated_at, new Date()],
]);
Works for conditions too:
await User.find([
[User.deleted, false],
query.name ? [sql`${User.name} LIKE ?`, `%${query.name}%`] : SKIP,
[User.status, query.status ?? SKIP],
]);
SKIP Behavior by Operation:
| Operation | SKIP Behavior |
|---|---|
find / findOne / count | Condition excluded from WHERE |
create / update | Column excluded from INSERT/UPDATE |
createMany | Column excluded β DB DEFAULT applied |
updateMany | Column excluded β existing value retained |
// createMany - SKIPped columns get DEFAULT value
await User.createMany([
[[User.name, 'John'], [User.email, 'john@test.com']],
[[User.name, 'Jane'], [User.email, SKIP]], // email = DEFAULT
]);
// updateMany - SKIPped columns unchanged
await User.updateMany([
[[User.id, 1], [User.email, 'new@test.com'], [User.status, SKIP]], // status unchanged
[[User.id, 2], [User.email, SKIP], [User.status, 'active']], // email unchanged
], { keyColumns: User.id });
Batch SQL Strategy by Database:
| Database | createMany | updateMany |
|---|---|---|
| PostgreSQL | Grouped UNNEST INSERT per SKIP pattern | UNNEST + CASE WHEN skip_flag |
| MySQL | Grouped VALUES ROW INSERT per SKIP pattern | JOIN VALUES ROW + IF(skip_flag) |
| SQLite | Grouped VALUES INSERT per SKIP pattern | CASE WHEN key=? THEN col ELSE ? |
Records with the same SKIP pattern are batched together for efficient INSERT. Each database uses native batch syntax while ensuring SKIPped columns receive DEFAULT values (createMany) or retain existing values (updateMany).
Note on
createManywith SKIP:createManygroups records by their SKIP pattern and issues a separate INSERT per group, because SQL does not allow mixingDEFAULTwith array-based bulk insert (UNNEST).updateManydoes not have this limitation β it handles SKIP in a single query using boolean flags. For best performance withcreateMany, prefer providing explicit values for all columns instead of using SKIP.
Define relations declaratively with type-safe decorators:
import { DBModel, model, column, hasMany, belongsTo, hasOne } from 'litedbmodel';
@model('users')
class UserModel extends DBModel {
@column() id?: number;
@column() name?: string;
// Use 'declare' for relation properties (not '!' assertion)
// This prevents TypeScript from creating instance properties that shadow the getter
@hasMany(() => [User.id, Post.author_id])
declare posts: Promise<Post[]>;
@hasOne(() => [User.id, UserProfile.user_id])
declare profile: Promise<UserProfile | null>;
}
export const User = UserModel.asModel();
@model('posts')
class PostModel extends DBModel {
@column() id?: number;
@column() author_id?: number;
@column() title?: string;
@belongsTo(() => [Post.author_id, User.id])
declare author: Promise<User | null>;
@hasMany(() => [Post.id, Comment.post_id])
declare comments: Promise<Comment[]>;
}
export const Post = PostModel.asModel();
// Usage
const post = await Post.findOne([[Post.id, 1]]);
const author = await post.author; // Lazy loaded
const comments = await post.comments; // Lazy loaded
Important: Use
declare(not!) for relation properties. TypeScript class field declarations with!create instance properties that shadow the prototype getter. The ESLint plugin detects this mistake.
@hasMany(() => [User.id, Post.author_id], {
order: () => Post.created_at.desc(),
where: () => [[Post.is_deleted, false]],
})
declare activePosts: Promise<Post[]>;
// Per-parent limit - fetch only N records per parent key
@hasMany(() => [User.id, Post.author_id], {
limit: 5,
order: () => Post.created_at.desc(),
})
declare recentPosts: Promise<Post[]>; // Each user gets their 5 most recent posts
The limit option applies SQL-level limiting per parent key during batch loading:
LATERAL JOIN for efficient per-group limitingROW_NUMBER() OVER (PARTITION BY ...) window functionThis is more efficient than fetching all records and filtering in application code.
Important: Always use
orderwithlimit. Without ordering, the "which N records" is non-deterministic and may vary between queries.
@model('tenant_posts')
class TenantPostModel extends DBModel {
@column({ primaryKey: true }) tenant_id?: number;
@column({ primaryKey: true }) id?: number;
@column() author_id?: number;
@belongsTo(() => [
[TenantPost.tenant_id, TenantUser.tenant_id],
[TenantPost.author_id, TenantUser.id],
])
declare author: Promise<TenantUser | null>;
}
When find() returns multiple records, batch loading is automatic β no eager loading specification needed:
const users = await User.find([]); // Auto batch context created
for (const user of users) {
const posts = await user.posts; // First access batch loads ALL users' posts
}
// Total: 2 queries instead of N+1!
Write natural code (await user.posts); litedbmodel handles the optimization.
Prevent accidental loading of too many records with configurable hardLimits:
// Global configuration
DBModel.setConfig(config, {
findHardLimit: 1000, // find() throws if > 1000 records
hasManyHardLimit: 10000, // hasMany throws if > 10000 records total (batch)
});
// Or update later
DBModel.setLimitConfig({ findHardLimit: 500, hasManyHardLimit: 5000 });
When limits are exceeded, LimitExceededError is thrown:
import { LimitExceededError } from 'litedbmodel';
try {
const users = await User.find([]); // May throw if too many records
} catch (e) {
if (e instanceof LimitExceededError) {
console.log(`Limit ${e.limit} exceeded: got ${e.actualCount} records`);
}
}
Per-relation hardLimit override:
You can override the global hasManyHardLimit for specific relations:
@hasMany(() => [User.id, Post.author_id], {
hardLimit: 500, // Override global hasManyHardLimit for this relation
})
declare posts: Promise<Post[]>;
@hasMany(() => [User.id, Log.user_id], {
hardLimit: null, // Disable limit check for this relation
})
declare logs: Promise<Log[]>;
Note:
findHardLimitandhasManyHardLimitare safety guards implemented asLIMIT N+1at SQL level. If the result exceeds the limit, it throws immediately β this minimizes data transfer while detecting overflow. For explicit SQL-level limiting (e.g., "N records per parent"), use thelimitoption described in With Options.
// Basic
await DBModel.transaction(async () => {
const user = await User.findOne([[User.id, 1]]);
await Account.update([[Account.user_id, user.id]], [[Account.balance, 100]]);
});
// With return value
const user = await DBModel.transaction(async () => {
return await User.create([[User.name, 'Alice']]);
});
// Auto-retry on deadlock
await DBModel.transaction(
async () => { /* ... */ },
{ retryOnError: true, retryLimit: 3 }
);
// Preview mode (rollback after execution)
await DBModel.transaction(
async () => { /* ... */ },
{ rollbackOnly: true }
);
Class-based middleware for cross-cutting concerns.
All database operations flow through the middleware system:
flowchart TD
subgraph "High-Level API"
find["find()"]
findOne["findOne()"]
findById["findById()"]
count["count()"]
create["create()"]
createMany["createMany()"]
update["update()"]
updateMany["updateMany()"]
delete["delete()"]
end
subgraph RelationAPI["Relation API"]
belongsTo["@belongsTo"]
hasMany["@hasMany"]
hasOne["@hasOne"]
end
subgraph "Middle-Level API"
query["query()"]
end
subgraph "Low-Level API"
execute["execute()"]
end
subgraph "Database"
DB[(DB)]
end
find --> query
findOne --> query
findById --> execute
belongsTo --> query
hasMany --> query
hasOne --> query
count --> execute
create --> execute
createMany --> execute
update --> execute
updateMany --> execute
delete --> execute
query --> execute
execute --> DB
style RelationAPI fill:#e0e0e0,stroke:#999
Middleware hooks:
find, findOne, findById, count, create, createMany, update, updateMany, deletequery β returns model instances from raw SQLexecute β intercepts ALL SQL queries (SELECT, INSERT, UPDATE, DELETE)Note: Relation API (
@belongsTo,@hasMany,@hasOne) bypasses method-level middleware hooks and callsquery()directly. To intercept relation queries, use Instantiation-level (query) middleware.
// Simple logger (no state needed)
const LoggerMiddleware = DBModel.createMiddleware({
execute: async function(next, sql, params) {
console.log('SQL:', sql);
return next(sql, params);
}
});
// With per-request state (fully type-safe)
const TenantMiddleware = DBModel.createMiddleware({
// Initial state for each request (deep-cloned per request)
state: { tenantId: 0, queryCount: 0 },
// Hook signature: (model, next, ...args) for method-level hooks
find: async function(model, next, conditions, options) {
// `this` is typed as { tenantId: number; queryCount: number }
this.queryCount++;
const tenantCol = (model as { tenant_id?: Column }).tenant_id;
if (tenantCol) {
conditions = [[tenantCol, this.tenantId], ...conditions];
}
return next(conditions, options);
}
});
// Register
DBModel.use(LoggerMiddleware);
DBModel.use(TenantMiddleware);
// Per-request usage (type-safe)
TenantMiddleware.getCurrentContext().tenantId = req.user.tenantId;
console.log(TenantMiddleware.getCurrentContext().queryCount);
State lifecycle: Each HTTP request gets its own copy of the state object via AsyncLocalStorage. States are isolated between concurrent requests and automatically cleaned up when the request ends.
When find() isn't enough, use real SQL directly. No query builder translation needed.
Portability note: Tuple API (
find(),create(),update()) and relation loading are DB-portable (config-only switching). Raw SQL viaquery()is your escape hatch for DB-specific optimizationsβyou control the dialect (placeholders, functions, type casts).
Execute any SQL and get typed model instances. The SQL you write is exactly what runs.
// Complex JOIN with subquery - returns User[] with full type safety
const activeUsers = await User.query(`
SELECT u.*
FROM users u
INNER JOIN (
SELECT user_id, COUNT(*) as order_count
FROM orders
WHERE created_at >= $1
GROUP BY user_id
HAVING COUNT(*) >= $2
) active ON u.id = active.user_id
WHERE u.status = 'active'
ORDER BY active.order_count DESC
`, [lastMonth, minOrders]);
// Window functions, CTEs, recursive queries - anything PostgreSQL supports
@model('user_rankings')
class UserRankingModel extends DBModel {
@column() user_id?: number;
@column() score?: number;
@column() rank?: number;
@column() percentile?: number;
}
const UserRanking = UserRankingModel.asModel();
const rankings = await UserRanking.query(`
WITH ranked AS (
SELECT
user_id,
score,
RANK() OVER (PARTITION BY category ORDER BY score DESC) as rank,
PERCENT_RANK() OVER (PARTITION BY category ORDER BY score) as percentile
FROM user_scores
WHERE created_at >= $1
)
SELECT * FROM ranked WHERE rank <= 100
`, [startDate]);
// rankings: UserRanking[] - full IDE autocomplete, type checking
Use execute() for DDL, maintenance, and operations that don't return model instances.
Accepts either raw SQL strings or sql tagged templates:
import { sql } from 'litedbmodel';
// With sql tag β parameters are co-located
await DBModel.execute(sql`SELECT process_daily_aggregates(${targetDate})`);
await DBModel.execute(sql`SELECT pg_notify(${'events'}, ${JSON.stringify(payload)})`);
// Raw SQL strings for DDL and maintenance
await DBModel.execute('REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_sales_summary');
await DBModel.execute('VACUUM ANALYZE orders');
await DBModel.execute('CREATE INDEX CONCURRENTLY idx_orders_date ON orders(created_at)');
| Method | Use Case | Returns |
|---|---|---|
Model.find() | Simple queries with conditions | Model[] |
Model.query() | Complex SQL returning model data | Model[] |
DBModel.execute() | DDL, maintenance, procedures | { rows, rowCount } |
| Query-Based Models | Reusable complex queries | Model[] via find() |
Define models backed by complex SQL queries instead of simple tables.
Use find(), findOne(), count() on JOINs, aggregations, CTEs, and analytics queries.
import { DBModel, model, column } from 'litedbmodel';
@model('user_stats') // Alias for the CTE
class UserStatsModel extends DBModel {
@column() id?: number;
@column() name?: string;
@column() post_count?: number;
@column() comment_count?: number;
@column() last_activity?: Date;
// Define the base query
static QUERY = `
SELECT
u.id,
u.name,
COUNT(DISTINCT p.id) AS post_count,
COUNT(DISTINCT c.id) AS comment_count,
GREATEST(MAX(p.created_at), MAX(c.created_at)) AS last_activity
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
LEFT JOIN comments c ON u.id = c.user_id
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.name
`;
}
export const UserStats = UserStatsModel.asModel();
// Use find() with additional conditions
const topContributors = await UserStats.find([
[sql`${UserStats.post_count} >= ?`, 10],
[sql`${UserStats.last_activity} > ?`, lastWeek],
], { order: UserStats.post_count.desc(), limit: 100 });
When find() is called, the QUERY becomes a CTE (WITH clause):
WITH user_stats AS (
SELECT
u.id,
u.name,
COUNT(DISTINCT p.id) AS post_count,
COUNT(DISTINCT c.id) AS comment_count,
GREATEST(MAX(p.created_at), MAX(c.created_at)) AS last_activity
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
LEFT JOIN comments c ON u.id = c.user_id
WHERE u.deleted_at IS NULL
GROUP BY u.id, u.name
)
SELECT * FROM user_stats
WHERE post_count >= $1 AND last_activity > $2
ORDER BY post_count DESC
LIMIT 100
sql TagFor queries that need runtime parameters, use the sql tagged template with withQuery(). Parameters are co-located with SQL β no manual $1/$2 numbering or DB dialect differences:
import { sql } from 'litedbmodel';
@model('sales_report')
class SalesReportModel extends DBModel {
@column() product_id?: number;
@column() product_name?: string;
@column() total_quantity?: number;
@column() total_revenue?: number;
@column() order_count?: number;
static forPeriod(startDate: string, endDate: string) {
return this.withQuery(sql`
SELECT
p.id AS product_id,
p.name AS product_name,
SUM(oi.quantity) AS total_quantity,
SUM(oi.quantity * oi.unit_price) AS total_revenue,
COUNT(DISTINCT o.id) AS order_count
FROM products p
INNER JOIN order_items oi ON p.id = oi.product_id
INNER JOIN orders o ON oi.order_id = o.id
WHERE o.status = 'completed'
AND o.created_at >= ${startDate}
AND o.created_at < ${endDate}
GROUP BY p.id, p.name
`);
}
}
export const SalesReport = SalesReportModel.asModel();
// Usage
const Q1Report = SalesReport.forPeriod('2024-01-01', '2024-04-01');
const topProducts = await Q1Report.find([
[sql`${SalesReport.total_revenue} > ?`, 10000],
], { order: SalesReport.total_revenue.desc() });
The sql tag automatically converts interpolated values to ? placeholders and extracts them as parameters. The internal dialect conversion (? β $1/$2 for PostgreSQL) is handled transparently.
Use the sql tag with Column references and Model classes for refactoring safety. Columns and table names are embedded directly in SQL; they are not parameterized:
import { sql } from 'litedbmodel';
@model('user_activity')
class UserActivityModel extends DBModel {
@column() user_id?: number;
@column() user_name?: string;
@column() total_posts?: number;
static QUERY = sql`
SELECT
${User.id} AS user_id,
${User.name} AS user_name,
COUNT(${Post.id}) AS total_posts
FROM ${User}
LEFT JOIN ${Post} ON ${User.id} = ${Post.user_id}
GROUP BY ${User.id}, ${User.name}
`;
}
Passing a Model class (${User}) in the sql tag expands to its TABLE_NAME. This is consistent with column references (${User.id} β column name).
| Use Case | Example |
|---|---|
| Aggregations | User stats, sales reports, leaderboards |
| Analytics | Cohort analysis, funnel metrics, trend data |
| Denormalized Views | Pre-joined data for read-heavy operations |
| Time-Series | Period-based summaries with window functions |
| Recursive Queries | Organizational hierarchies, category trees |
create(), update(), delete()find() condition paramsFor production deployments with read replicas, litedbmodel supports automatic connection routing.
DBModel.setConfig(
{ host: 'reader.db.example.com', database: 'mydb', ... }, // reader (default)
{
writerConfig: { host: 'writer.db.example.com', database: 'mydb', ... },
// Keep using writer after transaction (default: true)
// Avoids stale reads due to replication lag
useWriterAfterTransaction: true,
// Duration to keep using writer after transaction (ms, default: 5000)
writerStickyDuration: 5000,
}
);
| Context | Connection | Write Allowed |
|---|---|---|
Inside transaction() | Writer | β Yes |
Inside withWriter() | Writer | β No (SELECT only) |
| After transaction (within sticky duration) | Writer | β No |
| Normal query | Reader | β No |
Important: Write operations (create(), update(), delete()) require a transaction. Attempting to write outside a transaction throws an error.
// Override global useWriterAfterTransaction per transaction
await DBModel.transaction(
async () => {
await User.create([[User.name, 'John']]);
},
{
useWriterAfterTransaction: false, // Don't stick to writer after this transaction
}
);
Use withWriter() when you need to read from writer to avoid replication lag:
// Read from writer explicitly
const user = await DBModel.withWriter(async () => {
return await User.findOne([[User.id, 1]]);
});
// Write inside withWriter() throws error - use transaction() instead
await DBModel.withWriter(async () => {
await User.create([[User.name, 'Error']]); // β WriteInReadOnlyContextError
});
For applications connecting to multiple databases, use createDBBase() to create isolated base classes.
import { DBModel, model, column } from 'litedbmodel';
// Foundation database
const BaseDB = DBModel.createDBBase({
host: 'base-reader.example.com',
database: 'base_db',
// ...
}, {
writerConfig: { host: 'base-writer.example.com', database: 'base_db', ... },
});
// CMS database
const CmsDB = DBModel.createDBBase({
host: 'cms-reader.example.com',
database: 'cms_db',
// ...
}, {
writerConfig: { host: 'cms-writer.example.com', database: 'cms_db', ... },
});
// Models inherit from their respective database base class
@model('users')
class UserModel extends BaseDB {
@column() id?: number;
@column() name?: string;
}
export const User = UserModel.asModel();
@model('articles')
class ArticleModel extends CmsDB {
@column() id?: number;
@column() title?: string;
}
export const Article = ArticleModel.asModel();
Each database has its own transaction context:
// BaseDB transaction
await BaseDB.transaction(async () => {
await User.create([[User.name, 'John']]);
});
// CmsDB transaction (independent)
await CmsDB.transaction(async () => {
await Article.create([[Article.title, 'Hello World']]);
});
// Each DB also has independent withWriter()
const article = await CmsDB.withWriter(async () => {
return await Article.findOne([[Article.id, 1]]);
});
| Resource | Scope | Description |
|---|---|---|
| Connection Handler | Per DBBase | Each base class has its own connection pool |
| Transaction Context | Per DBBase | AsyncLocalStorage isolated per base class |
| Writer Context | Per DBBase | withWriter() isolated per base class |
| Sticky Timer | Per DBBase | Writer sticky duration tracked separately |
| Middlewares | Global | Cross-cutting concerns shared across all DBs |
| Model Registry | Global | For relation resolution across databases |
| Feature | litedbmodel | Kysely | Drizzle | TypeORM | Prisma |
|---|---|---|---|---|---|
| Relation Loading | On-demand | Manual | Eager/upfront | Eager/upfront | Include |
| Complex Queries | β Real SQL | Builder DSL | Builder DSL | HQL/Builder | Prisma DSL |
| Query-Based Models | β | β | β | Views only | Views only |
| Model-Centric Relations | β On-demand | β | β Eager | β Eager | β Include |
| Transparent N+1 Prevention | β | β Manual | β οΈ { with } | Eager only | Include |
| IDE Refactoring | β | β | β οΈ Partial | β | β |
| SKIP Pattern | β | β | β | β | β |
| Extensibility | Middleware | Plugins | β Manual | Subscribers | Extensions |
| Performance | π Fastest (9/19 wins) | Fast | Fast | Medium | Slow |
See COMPARISON.md for detailed analysis and BENCHMARK.md for benchmarks.
MIT
FAQs
A lightweight TypeScript ORM for PostgreSQL, MySQL, and SQLite with Active Record pattern
We found that litedbmodel demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago.Β It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.

Security News
Axios compromise traced to social engineering, showing how attacks on maintainers can bypass controls and expose the broader software supply chain.

Security News
Node.js has paused its bug bounty program after funding ended, removing payouts for vulnerability reports but keeping its security process unchanged.