Alt description missing in image
Now on ProductHunt! (50% off)
@db/mongooseREADME
Alt description missing in image

DB - Mongoose Plugin

npx @fullproduct/universal-app install with/db-mongoose

The @db/mongoose plugin adds a production-ready MongoDB layer to your app using Mongoose behind the shared @db/driver API. Define your collections with Zod schemas; the driver turns them into Mongoose models and gives you a consistent CRUD API with typed query filters, optional soft deletes, and serverless-friendly connection handling.

import { createSchemaModel } from '@db/driver'
import { DBEntity } from '@db/driver'
@app/core/models/Users.model.ts
// Define schema by extending DBEntity (includes `__v` version key)
export const UserSchema = DBEntity.extendSchema('User', {
    name: z.string(),
    email: z.string().email(),
})
 
// Includes `_id` and `__v` fields
export type User = MongoEntity<typeof UserSchema>
 
// Create the DB model from the schema
export const Users = createSchemaModel(UserSchema)

Driver usage (portability first):

// Driver usage (better portability and abstraction)
import { Users } from '@db/models'
 
// Execute driver methods
const newUser = await Users.insertOne({ name: 'Jane', email: 'jane@example.com' })
const foundUser = await Users.findOne({ email: 'jane@example.com' })
await Users.updateOne({ id: newUser.id }, { name: 'Jane Doe' })
await Users.deleteOne({ id: newUser.id })
 
// Can still use Mongoose methods on the '._raw' property
const doc = await Users._raw.findById(newUser.id)
const list = await Users._raw.find({ role: 'member' }).limit(10).lean()
const updated = await Users._raw.findOneAndUpdate(
  { email: 'jane@example.com' },
  { $set: { name: 'Jane Doe' } },
  { new: true }
)
const agg = await Users._raw.aggregate([{ $match: { role: 'member' } }, { $count: 'total' }])

vs. Direct usage (Mongoose first):

// OR direct usage (can use as a normal Mongoose model for more control / complexity)
import { Users } from '@app/core/models/Users.model'
 
// Execute Mongoose methods directly
const doc = await Users.findById(newUser.id)
const list = await Users.find({ role: 'member' }).limit(10).lean()
const updated = await Users.findOneAndUpdate(
  { email: 'jane@example.com' },
  { $set: { name: 'Jane Doe' } },
  { new: true }
)
const agg = await Users.aggregate([{ $match: { role: 'member' } }, { $count: 'total' }])
 
// Can still use driver methods on the 'driver' property
const foundUser = await Users.driver.findOne({ email: 'jane@example.com' })
await Users.driver.updateOne({ id: newUser.id }, { name: 'Jane Doe' })
await Users.driver.deleteOne({ id: newUser.id })

Why use @db/mongoose?

  • Single source of truth — Your Zod schemas drive both validation and the MongoDB schema. No duplicating field definitions. Always in sync.
  • Portable features — Code that uses @db/driver (e.g. createSchemaModel, DBEntity) works with Mongoose today and (after a data migration) can switch to another DB driver later with zero API changes.
  • Familiar Mongo semantics — Query with $and, $or, $eq, $in, nested fields, and the rest of the supported operators. Same mental model as the Mongo shell.
  • Ready for serverless — Connection is cached and reused across invocations; bufferCommands: false avoids buffering issues in serverless runtimes.

For the full driver abstraction story, see Workspace drivers.

Mongoose vs. other NoSQL DBs?

With a typical NoSQL store (or the raw MongoDB driver), you get flexible documents but little structure: no enforced schema, no built-in validation, and you hand-roll typing and defaults. Mongoose sits on top of MongoDB as an ODM (object–document mapper) and adds a schema layer: field types, required/optional rules, defaults, and indexes. That gives you a stable, documented shape for each collection and catches bad data at the app boundary instead of in the database. In this plugin, that schema is driven by Zod (one definition for both validation and Mongo), so you still get the flexibility of documents with the guardrails of a schema. If you want schema, validation, and a consistent API without giving up MongoDB’s query power, Mongoose (and this driver) is a good fit; if you prefer a schemaless, bare-metal NoSQL style, the raw driver or another store may be a better match.

Mongoose vs. SQL DBs?

Mongoose is a great fit for many use cases, but it’s not a one-size-fits-all solution. If you need the ACID guarantees of a relational database, use a SQL DB like PostgreSQL or MySQL. If you need a schemaless, flexible document store, use a NoSQL DB like MongoDB. If you need a mix of both, use a hybrid approach with a SQL DB for the ACID guarantees and a NoSQL DB for the flexibility.

Mongoose driver quickstart

1. Install the plugin

npx @fullproduct/universal-app install with/db-mongoose

This adds the @db/mongoose workspace and registers it in the drivers registry. The registry is regenerated automatically when you run npm run dev or npx turbo run @green-stack/core#collect-drivers.

2. Set your connection string

Add one of these to your env (e.g. apps/next/.env.local):

MONGODB_URI=mongodb://localhost:27017/your-db
# or
DB_URL=mongodb://localhost:27017/your-db

Use a hosted URI (e.g. MongoDB Atlas) in production. Keep these vars server-side only.

3. Select Mongoose in app config

features/@app-core/appConfig.ts
import { DRIVER_OPTIONS, createDriverConfig } from '@app/registries/drivers.config'
 
export const appConfig = {
  // ...
  drivers: createDriverConfig({
    db: DRIVER_OPTIONS.db.mongoose,
    // payments: DRIVER_OPTIONS.payments.stripe,
  }),
} as const

4. Define a schema and model

import { createSchemaModel, DBEntity } from '@db/driver'
import type { MongoEntity } from '@db/mongoose/schemas/MongoEntity.schema'
import { z } from '@green-stack/schemas'
 
const User = DBEntity.extendSchema('User', {
  name: z.string(),
  email: z.string().email(),
})
 
export type User = MongoEntity<typeof User>
export const Users = createSchemaModel(User)

5. Run collect-models

This happens automatically when you run npm run dev, but to run it manually:

npx turbo run @green-stack/core#collect-models

This will generate the @registries/models.generated.ts file, which ensures you can import the driver models from:

import { Users } from '@db/models'

6. Use the driver model

import { Users } from '@db/models'
 
const user = await Users.driver.insertOne({ name: 'Jane', email: 'jane@example.com' })
const found = await Users.driver.findOne({ email: 'jane@example.com' })
await Users.driver.updateOne({ id: user.id }, { name: 'Jane Doe' })

7. Use the native Mongoose APIs where necessary

Sometimes the driver methods are not enough, you can still access the raw Mongoose model via ._raw:

import { Users } from '@db/models'
 
const doc = await Users._raw.findById(user.id)
const list = await Users._raw.find({ role: 'member' }).limit(10).lean()
const updated = await Users._raw.findOneAndUpdate(
  { email: 'jane@example.com' },
  { $set: { name: 'Jane Doe' } },
  { new: true }
)
const agg = await Users._raw.aggregate([{ $match: { role: 'member' } }, { $count: 'total' }])

Workspace structure

        • mongoose.db.ts ← exports validated 'driver'
        • MongoEntity.schema.ts ← base entity (id, __v, …)
        • createSchemaModel.mongo.ts ← Zod → Mongoose + CRUD
        • dbConnect.mongo.ts ← cached connection
      • driver.signature.ts ← shared contract
        • db.drivers.generated.ts
      • drivers.config.ts ← db.mongoose option

Concepts

Zod as the source of truth

You define shapes with Zod (via @green-stack/schemas). createSchemaModel introspects the schema and builds a Mongoose schema from it, so types, validation, and the database stay in sync. Supported Zod types map to Mongoose types (string, number, boolean, date, object, array, enum, etc.). Unique and optional/nullable metadata is carried over.

DBEntity and MongoEntity

When Mongoose is the active DB driver, DBEntity (from @db/driver) is just an alias for MongoEntity. It extends the shared mock entity with Mongoose’s __v version key. Use DBEntity.extendSchema('YourModel', { ... }) for every collection; for TypeScript, export a type alias MongoEntity<typeof YourSchema> so documents are typed including _id and __v.

The model is a real Mongoose model

createSchemaModel(schema) returns a Mongoose model with an extra .driver property. So in “non-driver” mode - when you don’t use .driver — the object still behaves as a normal Mongoose model: you can call .find(), .findById(), .aggregate(), use it with Mongoose middleware, etc. Driver methods (and their behavior like soft deletes and auto updatedAt) live on .driver; the rest is standard Mongoose.

Connection handling

The driver uses a single cached connection (in dbConnect.mongo.ts). The first operation that needs the DB triggers mongoose.connect(); subsequent calls reuse that connection. Caching is keyed off globalThis so it survives hot reloads in development and re-use of the same process in serverless. bufferCommands: false is set so Mongoose does not buffer commands when not yet connected, which is recommended for serverless.

API overview

createSchemaModel(schema, modelName?)

Builds a Mongoose model from a Zod object schema and attaches a driver object with CRUD methods.

ArgumentDescription
schemaA Zod object schema (e.g. from DBEntity.extendSchema(...)).
modelName?Optional. Collection/model name. Defaults to the schema’s introspected name. Use a distinct name if you reuse the same schema for multiple collections or tests.

Returns a Mongoose model with an attached .driver property. So you get two ways to talk to the collection: the driver API (Model.driver.*) and the normal Mongoose API (Model.find, Model.findById, etc.). The object is a real Mongoose model; the driver is an extra layer on top.

Driver methods (CRUD)

MethodDescription
insertOne(record)Insert one document. Defaults from the schema are applied.
insertMany(records)Insert many documents.
findOne(query)Find a single document; returns undefined if none.
findMany(query)Find all documents matching the query.
findOrCreate(query, record)Find by query or insert record if none.
updateOne(query, updates, errorOnUnmatched?)Update one. If errorOnUnmatched === true and no document matches, throws.
updateMany(query, updates, errorOnUnmatched?)Update all matching documents.
upsertOne(query, record)Update one if found, otherwise insert.
deleteOne(query, errorOnUnmatched?)Delete one. With soft delete, sets the delete timestamp instead.
deleteMany(query, errorOnUnmatched?)Delete all matching (or soft-delete).

Aliases on the same driver object: create / createOne / createMany, read / readOne / readMany, modify / modifyOne / modifyMany, remove / removeOne / removeMany, plus insert, find, update, delete for the “one” variants.

Query filters

Query objects support Mongo-style operators so you can express filters in a familiar way:

  • Logical: $and, $or, $nor. (Top-level $not is not supported by MongoDB; use $nor to negate.)
  • Comparison: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin.
  • Nested fields: e.g. { 'meta.version': 1 } or { meta: { version: 1 } }.

Types come from @db/driver/utils/createSchemaModel.types (QueryFilterType, etc.).

Driver metadata (advanced)

The attached driver object also exposes internal metadata (for tooling or advanced use):

  • _key — Model/collection key.
  • _raw — The Mongoose model.
  • _schema — The Zod schema.
  • _idFields, _createdAtField, _updatedAtField, _softDeleteField — Detected field names.
  • _dbDriverType'mongoose'.

Accessing the raw Mongoose model explicitly

If you need to pass “just” the Mongoose model to a helper or library, use Model.driver._raw. When Model comes from a module that uses createSchemaModel from @db/driver (e.g. Users from Users.model.ts), that’s Users.driver._raw. It’s the same underlying model reference; useful when you want to be explicit or when the other code expects an unadorned Mongoose model.

import { Users } from '@app/core/models/Users.model'
 
const rawModel = Users.driver._raw
// e.g. pass to a function that expects a Mongoose Model
someLib.useModel(rawModel)

Schema conventions

The driver detects a few naming conventions and uses them for behavior:

ConventionField names (examples)Effect
CreatedcreatedAt, insertedAt (date)Not auto-set on insert; use schema defaults if you want.
UpdatedupdatedAt, modifiedAt, editedAt (date)Set automatically on updateOne / updateMany / upsertOne.
Soft deletedeletedAt, removedAt (date)deleteOne / deleteMany set this instead of removing the document; find/update skip documents where this is set.

Define these as z.date().default(() => new Date()) (or optional) in your schema so types and behavior line up.

Soft deletes

If your schema has a date field whose name suggests “deleted” or “removed”, the driver will treat it as a soft-delete field:

  • Delete: deleteOne / deleteMany set that field to new Date() instead of removing the document.
  • Read/update: All find and update methods exclude documents where the soft-delete field is present (via $exists: false on that field).

Examples

Full schema and model file

features/@app-core/schemas/User.schema.ts
import { DBEntity } from '@db/driver'
import type { MongoEntity } from '@db/mongoose/schemas/MongoEntity.schema'
import { z } from '@green-stack/schemas'
 
export const User = DBEntity.extendSchema('User', {
  name: z.string(),
  email: z.string().email(),
  role: z.enum(['admin', 'member']).default('member'),
})
 
export type User = MongoEntity<typeof User>
features/@app-core/models/Users.model.ts
import { createSchemaModel } from '@db/driver'
import { User } from '../schemas/User.schema'
 
export const Users = createSchemaModel(User)

Basic CRUD

import { Users } from '@db/models'
 
// Create
const user = await Users.insertOne({ name: 'Jane', email: 'jane@example.com' })
 
// Read
const found = await Users.findOne({ email: 'jane@example.com' })
const all = await Users.findMany({ role: 'member' })
 
// Update
const updated = await Users.updateOne({ id: user.id }, { name: 'Jane Doe' })
 
// Delete
await Users.deleteOne({ id: user.id })

findOrCreate and upsertOne

// Find or create by a unique key
const account = await Users.findOrCreate(
  { email: 'jane@example.com' },
  { name: 'Jane', email: 'jane@example.com' }
)
 
// Update if exists, otherwise insert
const updatedOrNew = await Users.upsertOne(
  { email: 'jane@example.com' },
  { name: 'Jane', email: 'jane@example.com', role: 'admin' }
)

Query filters

// Logical
const activeAdmins = await Users.findMany({
  $and: [{ role: 'admin' }, { deletedAt: { $exists: false } }],
})
 
// Comparison
const recent = await Users.findMany({
  createdAt: { $gte: new Date('2025-01-01') },
})
const byRoles = await Users.findMany({
  role: { $in: ['admin', 'member'] },
})
 
// Nested
const withMeta = await Users.findMany({
  'meta.version': 1,
})

Testing

The package ships with integration tests that use mongodb-memory-server so you can run them without a real MongoDB instance.

npm run test -w @db/mongoose

This starts an in-memory MongoDB, sets MONGODB_URI, and runs the test file. For details, see packages/@db-mongoose/utils/createSchemaModel.mongo.test.ts. You can use the same pattern in your app tests: spin up MongoMemoryServer in beforeAll, set process.env.MONGODB_URI, then run your code that uses @db/driver.

Troubleshooting

”DB_URL / MONGODB_URI are not defined”

Ensure at least one of DB_URL or MONGODB_URI is set in the environment that runs your app (e.g. .env.local for the Next app). The driver reads the URL when the first connection is created.

”No DB driver found for key “mongoose""

The driver registry hasn’t picked up @db/mongoose. Run:

npx turbo run @green-stack/core#collect-drivers

Then confirm appConfig.drivers.db is set to DRIVER_OPTIONS.db.mongoose and that db.drivers.generated.ts imports the mongoose driver.

Multiple DB drivers installed

You can have several DB driver packages in the workspace (e.g. mongoose and mock-db). Choose a single active implementation in appConfig.ts; @db/driver will resolve to that one. Use the mock driver for tests or local dev without MongoDB if needed.

Need the raw Mongoose model

When your model is created with createSchemaModel from @db/driver (e.g. Users from your Users.model.ts), use Users.driver._raw to get the underlying Mongoose model. Use it when you need to pass a “plain” Mongoose model to a helper or library, or for APIs not exposed on the driver. Prefer the driver API when possible so behavior stays consistent across implementations.

Reference

  • Driver contract: @db/driver/driver.signature.tscreateSchemaModel, DBEntity.
  • Query filter types: @db/driver/utils/createSchemaModel.types.tsQueryFilterType, QueryOperators, LogicalOperators.
  • Workspace drivers: Workspace drivers — How drivers are registered and selected.
💡

If you need multiple DB drivers installed side‑by‑side (e.g. migration or multi-db), install the packages you need and select one main driver in appConfig.drivers.db. All imports from @db/driver use that chosen implementation.