js-mongo-orm
v1.0.0
Published
A tiny, fast, type-safe active-record ORM for MongoDB with transparent dirty tracking and partial updates.
Maintainers
Readme
MongoORM
A tiny, fast, type-safe active-record ORM for MongoDB.
Define a class, mutate its fields, call save() — only what actually changed is written.
MongoORM is a minimal active-record layer over the official mongodb driver. It gives you ergonomic models without a schema compiler, a query builder, or a heavyweight runtime — just plain classes whose changes are tracked transparently and persisted as surgical partial updates.
class User extends Model {
name!: string;
profile!: { bio: string; city: string };
}
const user = await User.findOrFail(id);
user.profile.city = "Yerevan"; // deep mutation, tracked automatically
await user.save(); // → { $set: { "profile.city": "Yerevan" } }That last line sends only the changed nested path to MongoDB — not the whole document.
Contents
- Features
- Requirements
- Installation
- Quick start
- Why MongoORM?
- Performance
- How it works
- API reference
- Good to know
- Testing
- Development
- License
Features
- 🪶 Tiny. A handful of files, one dependency (
mongodb). No schema layer, no codegen. - ✨ Transparent dirty tracking. Read and mutate fields like a normal object — including nested ones. The model knows exactly what changed.
- 🎯 Surgical partial updates.
save()emits precise$set/$unsetoperations down to nested dot-paths, never a blind full-document overwrite. - ⚡ Built for throughput. Lazy change-snapshots keep reads cheap;
saveMany()collapses a write loop into a singlebulkWrite(~24× faster on large batches — see Performance). - 🔒 Type-safe. Written in TypeScript, ships
.d.ts. Your models are real classes with real types. - 🧩 Familiar API.
find,findOrFail,findMany,save,delete,serialize— if you've used Eloquent or Active Record, you already know it. - 🛟 Guarded fields & hooks. Hide sensitive fields from serialization; run
beforeSave()logic automatically.
Requirements
- Node.js ≥ 16
- MongoDB server reachable via a connection string
- Peer/runtime dependency:
mongodb^5
Installation
npm install js-mongo-orm mongodbQuick start
1. Connect
import { DB } from "js-mongo-orm";
await DB.createDBConnection({
name: "main", // logical name your models reference
db_name: "app", // database on the server
type: "MongoDB",
connectionString: process.env.MONGO_URL!,
});Single database? The first connection you register automatically becomes the default, so your models can skip
$dbentirely. With several connections, mark one withdefault: true(or callDB.setDefault(name)) to choose it.
2. Define a model
import { Model, ObjectId } from "js-mongo-orm";
class User extends Model {
protected $db = "main"; // optional — omit to use the default connection
protected $guarded = ["password"]; // never serialized / enumerated
_id!: ObjectId;
name!: string;
email!: string;
password!: string;
}The collection name is inferred from the class name (User → users, UserModel → user_models). Override it with protected $table = "people" when you need to.
The
!(definite assignment) tells TypeScript these fields are populated at runtime by the ORM, not in a constructor.
3. Create, read, update, delete
// Create
const user = new User();
user.name = "Ada";
user.email = "[email protected]";
await user.save(); // inserts; user._id is now populated
// Read
const byId = await User.find(user._id); // User | undefined
const orFail = await User.findOrFail(user._id); // User | throws
const admins = await User.findMany({ role: "admin" });
// Update (partial — only changed fields are written)
orFail.name = "Ada Lovelace";
await orFail.save(); // → { $set: { name: "Ada Lovelace" } }
// Delete (soft delete — stamps __deletedAt)
await orFail.delete();4. Nested updates just work
const user = await User.findOrFail(id);
user.settings.notifications.email = false;
delete user.settings.legacyFlag;
await user.save();
// → { $set: { "settings.notifications.email": false },
// $unset: { "settings.legacyFlag": true } }5. Batch writes with saveMany
const users = await User.findMany({ active: true });
users.forEach(u => (u.lastSeenAt = Date.now()));
await User.saveMany(users); // one bulkWrite() instead of N round-tripssaveMany groups inserts and updates per collection, issues a single bulkWrite / insertMany, backfills generated _ids, and re-baselines every model for further edits.
6. Lifecycle hooks
Define beforeSave() on a model and it runs automatically before every save() and saveMany():
class Post extends Model {
updatedAt!: number;
beforeSave() { this.updatedAt = Date.now(); }
}Why MongoORM?
| | MongoORM | Hand-written driver code | Heavy ODM |
| --- | --- | --- | --- |
| Partial nested updates | Automatic | Manual $set paths | Varies |
| Dirty tracking | Built-in | DIY | Yes |
| Bundle / runtime weight | Minimal | None | Large |
| Schema layer required | No | No | Usually |
| Batch helper | saveMany | DIY | Varies |
| Type-safe models | Yes | Partial | Yes |
If you love the raw driver but are tired of writing $set paths by hand and re-implementing change tracking on every project, MongoORM is the thin layer that removes that boilerplate — and nothing else.
Performance
Dirty tracking is implemented so that the common paths stay cheap:
- Reads are free. The change-baseline is captured lazily, only when a model is first mutated. Hydrating documents you only read never pays for a deep clone.
- Writes are batched.
saveMany()turns an N-document write loop into a single network round-trip.
Measured on a local MongoDB, N = 20,000 documents (npm run bench):
| Operation | Before | After | Speedup |
| --- | ---: | ---: | ---: |
| Hydrate 20k models (construction CPU) | 129 ms | 49 ms | ~2.6× |
| Persist 2k changed docs | 1751 ms (save loop) | 73 ms (saveMany) | ~24× |
| Read a field through a model | — | ~0.2 µs | negligible |
Numbers vary with hardware and network latency. The
saveManyadvantage grows on remote databases, where round-trip latency dominates.
How it works
Each model instance is wrapped in a Proxy. The handler:
- Intercepts writes and records them, while letting real getters/setters and class fields behave normally.
- Captures a pristine snapshot lazily — the first time you mutate a field (or read a nested object that could be mutated in place), so read-only models stay allocation-light.
- On
save(), deep-diffs the working copy against that snapshot and translates the result into precise Mongo$set/$unsetoperands, flattening nested objects into dot-paths.
The result: you write ordinary object code, and the database sees the smallest correct update.
API reference
DB — connection registry
| Member | Description |
| --- | --- |
| DB.createDBConnection(config) | Open and register a named connection. |
| DB.db(name?) | Resolve a Db by name, falling back to the default connection. |
| DB.setDefault(name) | Choose the default connection for models without $db. |
| DB.close(name?) | Close one connection, or all of them. |
| DB.connections / DB.dbs | Raw MongoClient / Db registries, keyed by name. |
ConnectionConfig:
| Field | Type | Description |
| --- | --- | --- |
| name | string | Logical name models reference via $db. |
| db_name | string | Database name on the server. |
| type | "MongoDB" | Driver to use. |
| connectionString | string | Standard MongoDB connection string. |
| default | boolean? | Make this the default connection. |
Model — instance methods
| Method | Description |
| --- | --- |
| save(needInsert?, bigUpdate?, sortProperties?) | Insert or partially update the document. |
| delete() | Soft-delete (stamps __deletedAt). |
| changes() | The sparse change tree since load / last save (undefined if clean). |
| serialize() | Plain object view with $guarded fields removed. |
save() options:
| Argument | Default | Effect |
| --- | --- | --- |
| needInsert | false | Force an insert even when a primary key is present. |
| bigUpdate | false | Replace whole top-level fields instead of computing nested dot-paths. |
| sortProperties | false | Sort keys before writing (deterministic output). |
Model — static methods
| Method | Description |
| --- | --- |
| find(filterOrKey) | One document → Model \| undefined. Accepts an ObjectId or a filter. |
| findOrFail(filterOrKey) | One document → Model, or throws if missing. |
| findMany(filter?, options?) | Array of models. Pass true as the 3rd argument to get the raw Mongo cursor instead. |
| saveMany(models, options?) | Batch insert/update in one round-trip per collection. |
saveMany() options:
| Option | Default | Effect |
| --- | --- | --- |
| bigUpdate | false | Same as save()'s bigUpdate, applied per model. |
| sortProperties | false | Sort each document's keys before writing. |
| ordered | false | Run as an ordered bulk write (slower, fail-fast). |
Model configuration (protected fields)
| Field | Default | Purpose |
| --- | --- | --- |
| $db | "" | Connection name to use. Empty ⇒ the default connection. |
| $table | derived | Collection name (defaults to the pluralized class name). |
| $primaryKey | "_id" | Field used for lookups and updates. |
| $guarded | ["password"] | Fields hidden from serialize() and key enumeration. |
Helpers
| Export | Description |
| --- | --- |
| generateTableName(className) | Class name → collection name (UserModel → user_models). |
| pluralize(word, singularize?) | The English inflector used for collection names. |
| ObjectId | Re-exported from the mongodb driver. |
Good to know
A few behaviors worth keeping in mind:
- Soft delete is opt-in on reads.
delete()only stamps__deletedAt;find/findManydo not filter deleted documents automatically. Add{ __deletedAt: { $exists: false } }to your queries (or a$wherehelper) if you want them hidden. undefineddoes not remove a field. Assigningmodel.x = undefinedis a no-op for deletion and logs a warning. Usedelete model.xto emit a$unset.- Snapshots clone via JSON. The dirty-tracking baseline is a structured (BSON-friendly) deep clone, so values that don't survive a JSON round-trip (functions, class instances with custom prototypes) aren't tracked field-by-field. Plain documents, dates, numbers, arrays, and nested objects all work as expected.
$guardeddefaults to["password"]. Override it per model — set it to[]to serialize everything.
Testing
The suite runs against a real MongoDB. Point it at any instance via MONGO_URL:
# spin up a throwaway MongoDB
docker run -d -p 27017:27017 --name mongo-orm-test mongo:6
MONGO_URL="mongodb://localhost:27017/mongoorm_test" npm testNo credentials are committed — the connection string comes entirely from the environment.
Development
npm run build # compile src/ → bin/
npm test # build + run jest
npm run bench # micro-benchmarks (needs MONGO_URL)Contributions are welcome — see CONTRIBUTING.md. Notable changes are recorded in CHANGELOG.md.
License
MIT © Alexander Asrumyan
