Mongoose adapter — @ascendance-hub/sapphire-mongoose

The Mongoose adapter converts a Sapphire IR (SapphireSchemaNode) into a mongoose.Schema (when the root node is an object) or a SchemaTypeDefinition (anywhere else). It is the closest match for Sapphire’s modeling style — Mongoose has a similar “schema-as-definition” surface — and is the adapter where the most modifiers survive at the DB level.

Unofficial. A community adapter — not affiliated with, sponsored, or endorsed by the Mongoose project or Automattic, Inc.

Install

npm install @ascendance-hub/sapphire-core @ascendance-hub/sapphire-mongoose mongoose

Both @ascendance-hub/sapphire-core and mongoose are peer dependencies. The package will not pull them in transitively — install them alongside.

Register the adapter

The adapter is not auto-registered. Call registerAdapter once in your application entry point — typically the same module that constructs your Sapphire instance:

import { Sapphire, registerAdapter } from '@ascendance-hub/sapphire-core'
import { toMongooseSchema } from '@ascendance-hub/sapphire-mongoose'

registerAdapter('mongoose', toMongooseSchema)

export const a = new Sapphire({ defaultAdapter: 'mongoose' })

registerAdapter is process-global. Calling it twice with the same name throws; calling it from a library is discouraged — leave it to the consuming application.

Quickstart

import mongoose from 'mongoose'
import { toMongooseSchema } from '@ascendance-hub/sapphire-mongoose'
import { a } from './sapphire'

const User = a
  .object({
    name: a.string().min(1),
    email: a.string().email().unique(),
    age: a.number().int().min(0).optional(),
  })
  .name('User')
  .timestamps()
  .index(['email'], { unique: true })

const UserSchema = User.getSchema('mongoose') as mongoose.Schema
const UserModel = mongoose.model('User', UserSchema)

field.getSchema('mongoose') is sugar for toMongooseSchema(field.toSchema()) once the adapter is registered — the latter is the explicit form when you want to skip the registry.

IR → Mongoose mapping

Every Sapphire IR kind lands somewhere in Mongoose’s SchemaTypeDefinition. The table below is exhaustive — what isn’t covered here is not part of the adapter’s surface.

IR kindMongoose outputNotes
string{ type: String, ... }minLength/maxLength/lengthminlength/maxlength. regexmatch. format (email/url/uuid)/startsWith/endsWith → custom validate. transformstrim/lowercase/uppercase directly.
number{ type: Number, ... }min/max direct. exclusiveMin/exclusiveMax/int/multipleOf/finite/safe → custom validate.
boolean{ type: Boolean, ... }Universal modifiers only.
date{ type: Date, min, max }min/max accept Date instances.
object (root)mongoose.SchemaTop-level — pass directly to mongoose.model(name, schema).
object (nested)sub-Schema wrapped as { type: schema, required }Subdocs default to _id: false. Pass { subdocId: true } to opt back in.
array{ type: [item], required }item is buildField applied recursively.
tuple{ type: [Mixed], validate }Custom validator enforces length only. Per-position type-checking lives in core.
union{ type: Mixed, required }No DB-level checks — use safeParse for the canonical validation.
literal{ type: <ctor>, enum: [value] }Constructor inferred: Number for numeric, Boolean for boolean, otherwise String.
enum{ type: <ctor>, enum: [...values] }Constructor is Number when the first value is numeric, otherwise String.
record (string-keyed){ type: Map, of: <values>, required }When keys.kind is string, enum, or literal.
record (other){ type: Mixed, required }Mongoose Map keys are strings under the hood; non-string keys lose typing.
ref{ type: ObjectId, ref: <target>, required }<target> is the string passed to the named object’s .name(...).

Universal modifiers

These apply to every node kind unless noted:

ModifierEffect on Mongoose definition
requiredAlways set on the definition (Sapphire owns it — required: true/false).
uniquedef.unique = true.
indexdef.index = true. If passed { unique: true }, also sets def.unique = true.
default(v)def.default = v.
describe(s)def.description = s (introspection only — Mongoose ignores it at validation time).
enum([...])def.enum = [...] (clones the array).
nullable()No-op. Mongoose accepts null on non-required fields implicitly; there is no dedicated nullable flag.

[!WARNING] coerce() is silently dropped. Mongoose has its own cast layer that handles coercion universally (e.g. "42"42 for Number fields). If you need Sapphire-level coercion to run, pipe the input through safeParse before assigning to a Mongoose document.

Schema-level options

The root-level ObjectField carries schema-wide flags that translate to Mongoose SchemaOptions:

Sapphire callMongoose effect
.name('User')Used by mongoose.model(name, schema) — the adapter does not call mongoose.model itself, it returns the bare Schema.
.timestamps()new Schema(def, { timestamps: true }) — Mongoose then auto-fills createdAt/updatedAt.
.index(['email', 'name'], { unique: true })Each call accumulates: schema.index({ email: 1, name: 1 }, { unique: true }). Multiple invocations stack.
.adapter('mongoose', { collection: 'people' }) (object-level)new Schema(def, { collection: 'people' }).

Refs

Sapphire refs resolve lazily by name:

const Post = a
  .object({
    title: a.string(),
    author: a.ref('User'),
  })
  .name('Post')

Emits { type: ObjectId, ref: 'User', required: true }. The string lands directly in Mongoose’s ref slot — Mongoose’s populate('author') then handles the lookup at query time. The adapter does not verify that a model named 'User' exists; that resolution happens when Mongoose runs the query. If you want a typed ref, use a.ref(SchemaObj) instead of the string form — the IR is identical, but the call site is typesafe against your name(...) registry.

See refs-and-relations.md for the full ref lifecycle.

.adapter('mongoose', opts) escape hatch

Values from .adapter('mongoose', { ... }) are read from node.meta.mongoose and merged into the field’s Mongoose definition last (after Sapphire-derived keys). They win on conflicts, with one exception — the blacklist:

const META_BLACKLIST = new Set(['type', 'required'])

type and required are always Sapphire-controlled; trying to override them via the escape hatch is a no-op.

Common keys:

KeyEffect
sparseMongoose-native sparse: true (sparse index).
collationMongoose-native collation: { locale: ... }.
validateAdds a custom Mongoose validator. Merged with Sapphire-derived ones if you pass an array.
selectselect: false to omit the field by default in query results.
immutableLock the field after creation.
aliasMongoose alias path.
descriptionMongoose SchemaType.options.description — surfaces in introspection.
collection (object-level only)Schema.options.collection.

Any other key Mongoose accepts on a SchemaTypeDefinition is honored verbatim — the adapter simply copies the entries onto the definition object.

MongooseAdapterOptions

The second argument to toMongooseSchema(node, options?):

OptionDefaultEffect
subdocIdfalseWhether nested object Schemas auto-add _id. Default deviates from Mongoose’s true — most apps don’t need _id on subdocuments.
rootId'auto'Root document _id strategy. 'auto' = Mongoose default (auto ObjectId unless you declare _id). 'none' = emit { _id: false }.
toMongooseSchema(node, { subdocId: true })

Custom root _id

To use a custom identity (string UUID, number, etc.) instead of the auto-generated ObjectId, declare a field literally named _id in the object. Mongoose honors a declared _id path and skips the auto ObjectId:

const User = a.object({
  _id: a.string(), // custom string id — you supply it on every insert
  name: a.string(),
})
toMongooseSchema(User.toSchema()) // → Schema with a String _id, no auto ObjectId

To strip the root _id entirely (rare — capped logs, views):

toMongooseSchema(User.toSchema(), { rootId: 'none' }) // → Schema with { _id: false }

rootId: 'none' is ignored when the schema already declares its own _id field — a field you explicitly asked for is never stripped.

Limitations

[!WARNING] Tuples enforce length only at the DB level. Per-position type checking lives in safeParse — Mongoose receives [Mixed] plus a length validator. If you need per-position checks before persistence, run safeParse on the value first.

[!WARNING] Unions degrade to Mixed. No Mongoose-level validation. Pair with safeParse to get the discriminated-union check.

[!WARNING] record with non-string keys falls back to Mixed. Mongoose’s Map keys are always strings — a.record(a.number(), ...) loses key typing in the DB. The keys.kind ∈ { string, enum, literal } path uses Map correctly.

[!WARNING] nullable() is a no-op. Mongoose has no dedicated nullable flag; non-required fields accept null implicitly. If you need strict null rejection, validate via safeParse.

[!WARNING] nullable() + required differs across Sapphire and Mongoose. Sapphire treats null as a valid value when nullable: true is set. Mongoose, by default, treats null as missing for the required check. Result: a field declared as a.string().nullable() (with the default required: true) will pass Sapphire.safeParse(null) but Mongoose .validate() will reject null on the same path. If you want Mongoose to also accept null without filling, pass an explicit default: null via the escape hatch (.adapter('mongoose', { default: null })), or drop nullable() and validate elsewhere.

[!WARNING] coerce() is dropped. Use safeParse before handing values to Mongoose if you want Sapphire’s coercion semantics.

[!WARNING] Subdocument _id: false is the default. Mongoose’s own default is true. Set subdocId: true on toMongooseSchema to opt back in.

[!WARNING] No discriminator / plugin / hook support. Sapphire emits a bare Schema. Mongoose’s discriminators, plugins, and middleware are deferred to V1_FUTURE — wire them on the returned Schema yourself if you need them now.