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, type Ref } from '@perryts/odm';
import bcrypt from 'bcrypt';
const UserSchema = z.object({
email: z.string().toLowerCase().trim(),
password: z.string().optional(),
passwordHash: z.string().optional(),
});
const User = defineModel('users', UserSchema, {
indexes: [{ keys: { email: 1 }, unique: true }],
});
before(User, 'insert', async (doc) => {
if (doc.password) {
doc.passwordHash = await bcrypt.hash(doc.password, 10);
doc.password = undefined;
}
});
const ItemSchema = 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(),
});
const Item = defineModel('items', ItemSchema, {
indexes: [
{ keys: { location: '2dsphere' } },
{ keys: { expireAt: 1 }, expireAfterSeconds: 0 },
],
});
const client = await MongoClient.connect(process.env.MONGO_URI!);
await initModels(client.db('app'), [User, Item]);
const alice = await User.insert({ email: 'alice@example.com', password: 'secret' });
await Item.insert({
title: 'Bike',
owner: alice._id as Ref<'users'>,
location: { type: 'Point', coordinates: [-122.42, 37.77] },
});
const items = await Item.find({}).populate('owner');
const near = await Item.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