MongoDB
Document database support via Mongoose with an ActiveRecord-style BaseModel, CRUD operations, soft deletes, pagination, and raw aggregation.
Installation
bun add @tekir/mongodb mongooseMongoose is a peer dependency; install the version you want. Requires a running MongoDB instance (local, Docker, or MongoDB Atlas).
The package's unit and security tests do not require MongoDB. The real integration suite runs when a MongoDB instance is reachable and is reported as skipped otherwise.
Configuration
Create a config file or pass the URI directly. The MongoProvider reads from config('mongodb').
// config/mongodb.ts
export default {
uri: process.env.MONGO_URI || 'mongodb://localhost:27017/myapp',
options: {
maxPoolSize: 10,
},
debug: false,
}Defining Models
Extend BaseModel and define your schema using Mongoose schema types.
import { BaseModel } from '@tekir/mongodb'
export class User extends BaseModel {
static modelName = 'User'
static schema = {
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
password: String,
age: Number,
role: { type: String, enum: ['user', 'admin'], default: 'user' },
tags: [String],
profile: {
bio: String,
avatar: String,
},
}
static fillable = ['name', 'email', 'password', 'age', 'role', 'tags', 'profile']
static hidden = ['password']
static config = { timestamps: true }
}Schema
The schema property uses Mongoose schema syntax: all Mongoose types are supported: String, Number, Boolean, Date, ObjectId, nested objects, arrays, enums, defaults, and validators.
Fillable & Hidden
fillable: only these fields are accepted increate()andupdate(). Mass-assignment protection.hidden: excluded fromtoJSON()output. Password fields, tokens, etc.
Timestamps
Set config.timestamps = true to automatically add createdAt and updatedAt fields.
CRUD Operations
Create
import { User } from '#models/user'
// Create a single document
const user = await User.create({
name: 'Ali',
email: '[email protected]',
password: 'hashed_password',
age: 25,
})
// Create many documents
const users = await User.createMany([
{ name: 'Veli', email: '[email protected]' },
{ name: 'Ayse', email: '[email protected]' },
])Read
// Find all
const users = await User.find()
// Find with filter
const admins = await User.find({ role: 'admin' })
// Find by ID
const user = await User.findById('507f1f77bcf86cd799439011')
// Find one
const ali = await User.findOne({ email: '[email protected]' })
// Find or fail (throws if not found)
const user = await User.findOrFail('507f1f77bcf86cd799439011')
// Check existence
const exists = await User.exists({ email: '[email protected]' })
// Count
const total = await User.count()
const adminCount = await User.count({ role: 'admin' })
// Distinct values
const roles = await User.distinct('role')Query filters passed to find, findOne, count, exists, deleteMany, and updateMany are sanitized before they reach MongoDB. Keys that start with $ or contain a . are stripped, which blocks operator-injection payloads such as { $ne: null }, $where, and $regex from untrusted input. So a filter built from request data, for example User.findOne({ email, password }) where email is { "$ne": null }, can no longer be turned into an authentication bypass. Genuine values like Date and ObjectId are preserved, so hand-written queries keep working.
Update
// Update by ID
const updated = await User.update(user._id, {
name: 'Ali Updated',
age: 26,
})
// Update many
const count = await User.updateMany(
{ role: 'user' },
{ $set: { verified: true } }
)Delete
// Delete by ID
await User.delete(user._id)
// Delete many
const count = await User.deleteMany({ role: 'guest' })Soft Deletes
Enable softDeletes: true in model config. Deleted documents get a deletedAt timestamp instead of being removed.
import { BaseModel } from '@tekir/mongodb'
export class Post extends BaseModel {
static modelName = 'Post'
static schema = {
title: { type: String, required: true },
body: String,
author: { type: String, required: true },
published: { type: Boolean, default: false },
}
static fillable = ['title', 'body', 'author', 'published']
static config = { timestamps: true, softDeletes: true }
}import { Post } from '#models/post'
// Soft delete: sets deletedAt timestamp
await Post.delete(post._id)
// find() automatically excludes soft-deleted documents
const posts = await Post.find() // only non-deleted
// Include soft-deleted documents
const all = await Post.withTrashed()
// Only soft-deleted documents
const trashed = await Post.onlyTrashed()
// Restore a soft-deleted document
await Post.restore(post._id)
// Permanently delete (bypasses soft delete)
await Post.forceDelete(post._id)Pagination
Built-in pagination with total count and page metadata.
// Paginate results
const page = await User.paginate({ role: 'user' }, 1, 20)
// page.data : array of documents
// page.total : total matching documents
// page.page : current page number
// page.perPage : items per page
// page.lastPage : last page number
// Example in a controller
router.get('/api/users', async ({ query }) => {
const page = Number(query.page) || 1
const perPage = Number(query.per_page) || 20
return await User.paginate({}, page, perPage)
})Aggregation
Use aggregate() for MongoDB aggregation pipelines, or query() to access the underlying Mongoose model directly.
// Raw MongoDB aggregation pipeline
const stats = await User.aggregate([
{ $group: { _id: '$role', count: { $sum: 1 }, avgAge: { $avg: '$age' } } },
{ $sort: { count: -1 } },
])
// Access the underlying Mongoose model for advanced queries
const model = User.query()
const result = await model
.find({ age: { $gte: 18 } })
.sort({ name: 1 })
.limit(10)
.select('name email')Unlike the filter methods above, aggregate() is a raw escape hatch: the pipeline you pass is run as-is and is not sanitized. Never build a pipeline directly from request data, an attacker-controlled stage such as $lookup, $out, or $merge could read or write collections you never intended to expose. Always construct pipeline stages from trusted, server-side values.
Multiple Connections
Each Mongo instance holds its own connection. Create as many as you need.
import { Mongo } from '@tekir/mongodb'
// Each Mongo instance holds its own connection
const main = new Mongo()
await main.connect({ uri: 'mongodb://localhost:27017/myapp' })
const analytics = new Mongo()
await analytics.connect({ uri: 'mongodb://localhost:27017/analytics' })
const logs = new Mongo()
await logs.connect({ uri: 'mongodb://localhost:27017/logs' })
// Use each connection independently
main.connection // Mongoose connection to myapp
analytics.connection // Mongoose connection to analytics
// Disconnect individually
await analytics.disconnect()
await logs.disconnect()
await main.disconnect()Raw Mongoose Access
For scripts, seeders, or advanced use cases, connect and disconnect manually.
// Manual connection (for scripts, seeders, etc.)
import { mongo } from '@tekir/mongodb'
await mongo.connect({ uri: 'mongodb://localhost:27017/myapp' })
const connection = mongo.connection // Mongoose Connection
const mongoose = mongo.mongoose // Mongoose instance
await mongo.disconnect()Provider
Register MongoProvider in your kernel to auto-connect on boot.
// config/mongodb.ts
export default {
uri: process.env.MONGO_URI || 'mongodb://localhost:27017/myapp',
}
// start/kernel.ts
import { MongoProvider } from '@tekir/mongodb'
export default function ({ app }) {
app.registerAll([MongoProvider])
}