Papr
Overview
@miiajs/papr wires Papr into MiiaJS. You declare collections with defineModel(), register them per feature module via PaprModule.register(), and inject them in services as plain DI tokens with inject(User). Raw mongodb Db is available through inject(paprDb()) when you need to drop down to driver-level operations (aggregations, transactions, etc).
The package owns the connection lifecycle (onInit/onDestroy), retry logic for transient connection errors, and Papr's updateSchemas() call. The internal PaprService is an implementation detail - users never inject it directly.
Installation
bun add @miiajs/papr mongodb papr
npm install @miiajs/papr mongodb papr
pnpm add @miiajs/papr mongodb papr
yarn add @miiajs/papr mongodb papr
Peer dependencies:
| Package | Version |
|---|---|
mongodb | ^7.1.1 |
papr | ^17.0.0 |
Setup
import { Module } from '@miiajs/core'
import { PaprModule } from '@miiajs/papr'
@Module({
imports: [
PaprModule.configure({
connection: {
url: 'mongodb://localhost:27017',
dbName: 'myapp',
},
}),
],
})
class AppModule {}
With ConfigService (from @miiajs/config):
import { ConfigService } from '@miiajs/config'
PaprModule.configure((resolve) => {
const config = resolve(ConfigService)
return {
connection: {
url: config.getOrThrow('MONGODB_URL'),
dbName: config.get('MONGODB_DB'),
retry: { attempts: 5, delay: 2_000 },
},
}
})
Configuration options
| Option | Type | Default | Description |
|---|---|---|---|
connection.url | string | required | MongoDB connection URI |
connection.dbName | string | from URI | Database name override |
connection.retry.attempts | number | 3 | Connection attempts before giving up |
connection.retry.delay | number | 2_000 | Delay (ms) between attempts |
Only network-level errors (ECONNREFUSED, ECONNRESET, ETIMEDOUT, ENOTFOUND, EAI_AGAIN) are retried. Auth and schema errors abort immediately.
Define models
defineModel(collection, schema) returns a class-token that doubles as a typed DI token for the model. Pass it to PaprModule.register() and to inject():
import { schema, types } from 'papr'
import { defineModel } from '@miiajs/papr'
export const userSchema = schema(
{
name: types.string({ required: true }),
email: types.string({ required: true }),
role: types.string({ required: true }),
password: types.string({ required: true }),
},
{
defaults: { role: 'user' },
timestamps: true,
},
)
export const User = defineModel('users', userSchema)
export type UserDocument = (typeof userSchema)[0]
defineModel is a class. Each call returns a fresh class, so two defineModel('users', schema) calls produce two distinct tokens - this is the supported way to point one collection at two different connections (see Multiple connections).Register models
Register models in the feature module that owns them:
import { Module } from '@miiajs/core'
import { PaprModule } from '@miiajs/papr'
import { User } from './user.schema.js'
@Module({
imports: [PaprModule.register([User])],
controllers: [UserController],
providers: [UserService],
})
class UserModule {}
Each registered token becomes its own DI provider keyed by the token class. Multiple register() calls accumulate into the same connection registry; the model is compiled by Papr during onInit.
Use in services
Inject models with the standard inject() from @miiajs/core:
import { Injectable, inject } from '@miiajs/core'
import { User } from './user.schema.js'
@Injectable()
class UserService {
private users = inject(User)
async findAll() {
return this.users.find({})
}
async findById(id: string) {
const { ObjectId } = await import('mongodb')
return this.users.findOne({ _id: new ObjectId(id) })
}
async create(data: { name: string; email: string; password: string }) {
return this.users.insertOne({
...data,
role: 'user',
createdAt: new Date(),
})
}
async update(id: string, data: Record<string, any>) {
const { ObjectId } = await import('mongodb')
return this.users.findOneAndUpdate(
{ _id: new ObjectId(id) },
{ $set: data },
{ returnDocument: 'after' },
)
}
async delete(id: string) {
const { ObjectId } = await import('mongodb')
const result = await this.users.deleteOne({ _id: new ObjectId(id) })
return result.deletedCount > 0
}
}
inject(User) returns a typed Model<TDoc, TOpts> proxy. The underlying Papr model is created during app.init(); until then the proxy is dormant - the first method call resolves it from the internal service.
Raw database access
For aggregations, transactions, or other driver-level operations, inject the raw mongodb Db:
import { Injectable, inject } from '@miiajs/core'
import { paprDb } from '@miiajs/papr'
@Injectable()
class StatsService {
private db = inject(paprDb())
async dailyActiveUsers() {
return this.db
.collection('events')
.aggregate([
{ $match: { type: 'login', createdAt: { $gte: startOfDay() } } },
{ $group: { _id: '$userId' } },
{ $count: 'count' },
])
.toArray()
}
}
paprDb(name?) is memoized: the same name (including the default empty name) always returns the same token, so DI identity is stable across files.
Multiple connections
Pass a name to configure() and register() for each additional database:
@Module({
imports: [
PaprModule.configure({
connection: { url: MAIN_DB, dbName: 'app' },
}),
PaprModule.configure(
{ connection: { url: ANALYTICS_DB, dbName: 'analytics' } },
'analytics',
),
UserModule,
EventModule,
],
})
class AppModule {}
Register each model under the connection it belongs to:
// UserModule - default connection
PaprModule.register([User])
// EventModule - named connection
PaprModule.register([Event], 'analytics')
If you need the same collection on two connections, declare two distinct tokens. They look identical to the user but DI sees them as separate providers:
export const User = defineModel('users', userSchema)
export const AnalyticsUser = defineModel('users', userSchema)
PaprModule.register([User]) // default
PaprModule.register([AnalyticsUser], 'analytics')
// In services:
private users = inject(User)
private analyticsUsers = inject(AnalyticsUser)
defineModel(...) per connection.For raw db access on the named connection:
private analyticsDb = inject(paprDb('analytics'))
Connection lifecycle
- onInit - validates that no two distinct tokens claim the same collection name, connects to MongoDB with retry on transient errors, registers all models with Papr, calls
papr.initialize()andpapr.updateSchemas(). If any step afterclient.connect()fails, the client is closed before the error is rethrown (no leaked sockets). - onDestroy - closes the MongoDB client and clears all model references.
Testing
Because models are DI tokens, you override them directly in tests with TestApp.override():
import { TestApp } from '@miiajs/core/testing'
import { AppModule } from './app.module.js'
import { User } from './user/user.schema.js'
const fakeUserModel = {
find: () => Promise.resolve([{ _id: '1', name: 'Alice' }]),
insertOne: async (doc: any) => ({ acknowledged: true, insertedId: 'fake' }),
} as any
const app = await TestApp.create(AppModule)
.override(User, fakeUserModel)
.compile()
const res = await app.request('GET', '/users')
expect(res.status).toBe(200)
await app.close()
For higher-level overrides (replace the whole service), use the standard TestApp.override(UserService, mock) pattern from @miiajs/core.
Exports
import {
PaprModule,
defineModel,
paprDb,
type PaprModuleOptions,
type ModelToken,
type PaprDbToken,
} from '@miiajs/papr'
See also
@miiajs/mongoose- Mongoose-based MongoDB integration with the same shape@miiajs/drizzle- SQL ORM integration following a similar pattern- Modules - DI scoping and module composition