Value-oriented MongoDB ODM for TypeScript. Sits on
@perryts/mongodb. Zero native
dependencies. Compiles to a native binary via
Perry (LLVM AOT).
npm install @perryts/odm @perryts/mongodb zodimport{z}from'zod';import{MongoClient}from'@perryts/mongodb';import{defineModel,ref,before,initModels,typeRef}from'@perryts/odm';importbcryptfrom'bcrypt';constUserSchema=z.object({email: z.string().toLowerCase().trim(),password: z.string().optional(),passwordHash: z.string().optional(),});constUser=defineModel('users',UserSchema,{indexes: [{keys: {email: 1},unique: true}],});before(User,'insert',async(doc)=>{if(doc.password){doc.passwordHash=awaitbcrypt.hash(doc.password,10);doc.password=undefined;}});constItemSchema=z.object({title: z.string(),owner: ref('users'),location: z.object({type: z.literal('Point'),coordinates: z.tuple([z.number(),z.number()]),}),expireAt: z.date().optional(),});constItem=defineModel('items',ItemSchema,{indexes: [{keys: {location: '2dsphere'}},{keys: {expireAt: 1},expireAfterSeconds: 0},],});constclient=awaitMongoClient.connect(process.env.MONGO_URI!);awaitinitModels(client.db('app'),[User,Item]);constalice=awaitUser.insert({email: 'alice@example.com',password: 'secret'});awaitItem.insert({title: 'Bike',owner: alice._idasRef<'users'>,location: {type: 'Point',coordinates: [-122.42,37.77]},});constitems=awaitItem.find({}).populate('owner');constnear=awaitItem.aggregate([{$geoNear: {near: {type: 'Point',coordinates: [-122.42,37.77]},distanceField: 'dist',maxDistance: 5000,spherical: true}},]).toArray();- Schemas are runtime values. A
defineModelcall takes a Zod object schema, an optional list of indexes, and an optionaltimestampsflag. There is noSchemaconstructor. - Documents are plain objects. No class wrapping, no
.save(), no Mongoose-style document mutation.Model.insert(obj)takes a Zod-input shape, validates it, writes it, and returns the stored doc with_id. - Refs are branded
ObjectIds.ref('users')is a Zod schema whose inferred type isObjectId & { __ref: 'users' }. The brand is whatpopulate('field')uses to look up the target collection at query-build time — no string typos pointing nowhere. populateis a query-builder concern.Model.find(filter).populate('owner')lowers to a$lookup-augmented aggregation pipeline. Multiple.populate()chains are stacked.- Indexes are declared, applied at startup.
initModels(db, [...])binds each model to a MongoDB collection and runscreateIndexesfor every declared index — including2dsphere, compound, unique, and TTL (expireAfterSeconds). - Hooks are functions, not middleware.
before(Model, 'insert' | 'update' | 'delete', fn)registers a hook.pre-savefor password hashing maps tobefore(User, 'insert', ...). - Aggregate is a passthrough.
Model.aggregate<T>(pipeline)returns the driver'sAggregationCursor<T>.$geoNear,$lookup,$facet, anything Mongo supports.
See MIGRATING_FROM_MONGOOSE.md — written as a rule book for an LLM agent doing a mechanical port.
- No first-class transactions API (use
client.startSession()and pass{ session }throughModel.collection.*). - No first-class change streams (use
Model.collection.watch(...)). - No plugin system. Compose with plain functions.
- No custom timestamp field names (
createdAt/updatedAtonly). populate(...).select(...)is not supported. Useaggregatewith a manual$projectstage if you need field selection on joins.- Discriminated unions are supported via Zod but
defineModelitself takes aZodObject. Validate the discriminator in abeforehook if you need a single Model handle.
MIT