MongoDB

Document database support via Mongoose with an ActiveRecord-style BaseModel, CRUD operations, soft deletes, pagination, and raw aggregation.

Installation

bun add @tekir/mongodb mongoose

Mongoose 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
// 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.

core/models/user.ts
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 in create() and update(). Mass-assignment protection.
  • hidden: excluded from toJSON() 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.

core/models/post.ts
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])
}