beta

Papr

MongoDB integration via Papr with schema validation, model registration, and standard `inject()` API.

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

Peer dependencies:

PackageVersion
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

OptionTypeDefaultDescription
connection.urlstringrequiredMongoDB connection URI
connection.dbNamestringfrom URIDatabase name override
connection.retry.attemptsnumber3Connection attempts before giving up
connection.retry.delaynumber2_000Delay (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]
The token returned by 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)
Registering the same token under two different connection names will log a warning and resolve to whichever provider was registered first. Always create a separate 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() and papr.updateSchemas(). If any step after client.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