@m1abdullah/dorm
v0.6.0
Published
Django-style ORM for MongoDB: field__lookup filters compiled to aggregation pipelines, with schema-driven models and validation.
Maintainers
Readme
DORM
Django-style ORM for MongoDB.
📖 Documentation — guides + API reference.
DORM brings the ergonomics of the Django ORM to MongoDB in TypeScript: define a
model once, then query and write through it — including Django's field__lookup
filter syntax, which compiles to MongoDB aggregation pipelines (relational __
traversals become $lookup joins).
The why
I've played with Django and the DRF, and one of the things that really amazed me was the Django ORM — powerful and genuinely fun to use. It felt like every major database deserved an ORM like that. That's why this project was born.
Install
npm install @m1abdullah/dorm mongodbmongodb is a peer dependency (DORM has no other runtime dependencies), so you
always control the single copy of the driver.
Requirements. Node.js 20+ and mongodb 6+. DORM is an ESM-only
package — there is no CommonJS build. Import it with ESM import syntax from an
ES module (or via a dynamic import() from CommonJS); a top-level
require("@m1abdullah/dorm") is not supported.
Quickstart
import { DORMClient, f } from "@m1abdullah/dorm";
const dorm = new DORMClient(process.env.MONGODB_URL!); // e.g. mongodb://localhost:27017/app
// Declare models synchronously — safe before connecting.
const User = dorm.model(
"users",
{
username: f.string({ required: true }),
email: f.string({
required: true,
validate: (v) => {
if (!v.includes("@")) throw new Error("Enter a valid email.");
},
}),
salary: f.number({ default: 0 }),
active: f.boolean({ default: true }),
supervisor: f.ref("users"), // reference to another users document
},
{ timestamps: true },
);
const Asset = dorm.model(
"assets",
{
name: f.string({ required: true }),
price: f.number({ default: 0 }),
assigned_to: f.ref("users"),
},
{ timestamps: true },
);
await dorm.connect(); // open the connection once, at startup
// Create — validates, applies defaults + timestamps, returns the stored doc.
const boss = await User.objects.create({ username: "boss", email: "[email protected]", salary: 90000 });
const abed = await User.objects.create({
username: "abed",
email: "[email protected]",
salary: 40000,
supervisor: boss._id,
});
await Asset.objects.create({ name: "Macbook", price: 2000, assigned_to: abed._id });
// Query — Django-style lookups.
await User.objects.filter({ salary__gte: 50000 }); // simple lookup
await User.objects.get({ username: "abed" }); // one or throws
await User.objects.count({ active: true });
// Relational traversal — assets whose assignee's supervisor earns >= 50k.
await Asset.objects.filter({ assigned_to__supervisor__salary__gte: 50000 });
// Write.
await User.objects.update({ username: "abed" }, { salary: 60000 }); // bumps updatedAt
await User.objects.delete({ active: false });
await dorm.close();Models & fields
dorm.model(name, schema, options?) returns a Model whose .objects manager
is your query/write surface. The schema is the single source of truth: it drives
type inference and runtime validation.
Field builders (f.*) all accept { required?, default?, validate?, unique?, index? }:
| Builder | Stored as | Notes |
| --- | --- | --- |
| f.string(opts?) | string | |
| f.number(opts?) | number | rejects NaN |
| f.boolean(opts?) | boolean | |
| f.date(opts?) | Date | ISO strings/timestamps are coerced |
| f.objectId(opts?) | ObjectId | hex strings are coerced |
| f.ref(collection, opts?) | ObjectId | supplies $lookup.from for __ traversal |
| f.array(element, opts?) | T[] | each element validated against element |
| f.embedded(schema, opts?) | object | validated against the sub-schema (typed) |
| f.enum(values, opts?) | union | value must be one of values |
const User = dorm.model("users", {
email: f.string({ required: true, unique: true }),
role: f.enum(["admin", "member"]), // typed as "admin" | "member"
tags: f.array(f.string()), // string[]
address: f.embedded({ // typed nested object
city: f.string({ required: true }),
zip: f.string({ default: "00000" }),
}),
});defaultmay be a value or a factory (() => new Date()) evaluated per create.validatethrows anErrorto reject; the message surfaces on the validation error.options.timestamps: truemanagescreatedAt/updatedAtautomatically.
Indexes
Fields marked unique: true or index: true become indexes when you call
ensureIndexes() (do this once after connect()):
await dorm.connect();
await dorm.ensureIndexes(); // all models, or User.ensureIndexes() for oneManager API (.objects)
| Method | Returns |
| --- | --- |
| all() | chainable QuerySet of every document (see Querying) |
| filter(where) · exclude(where) | chainable QuerySet |
| order_by(...) · limit(n) · offset(n) · select(...) | chainable QuerySet |
| get(where?) | exactly one document (throws DoesNotExist / MultipleObjectsReturned) |
| first() · exists() | first document | null, and a boolean |
| create(data) | the stored document (validated, with _id + timestamps) |
| update(where, patch) | { matched, modified } (partial-validates the patch) |
| delete(where) | { deleted } |
| count(where?) | number of matching documents |
Lookups
Append __lookup to a field name. Supported operators:
startswith, contains, icontains, gt, gte, lt, lte, in, nin,
exists, isnull, range (inclusive [min, max]).
User.objects.filter({ username__startswith: "ab" });
User.objects.filter({ salary__range: [40000, 80000] });
User.objects.filter({ supervisor__isnull: true });Filter keys are fully typed: your editor autocompletes valid field__lookup
keys for a model, checks each value against the field's type, and flags typos —
salary__gte expects a number, username__startswith a string, and so on.
_id is always available, and createdAt/updatedAt keys appear on models with
timestamps: true. Regex-based lookups escape their input, and only the operators
above are recognized — arbitrary MongoDB operators can't be injected through
filter keys.
Typing note (deep relational keys): the value on a deep relational key like
assignee__email__icontains is accepted loosely (typed as unknown) in the
filter — ref__… keys go through a relational passthrough, so you won't get
autocomplete/value-checking on the nested part the way you do for a top-level field
like email__icontains. It compiles and runs correctly; it's just not strictly
typed yet (that's a roadmap item).
Relational traversal: chain __ through f.ref fields to join across
collections. Register both models on the same client so DORM can resolve the
join targets. Note that traversal uses an inner join ($unwind), so documents
whose reference is null/unmatched are excluded.
Filter values for _id and f.ref/f.objectId fields are auto-coerced from
hex strings to ObjectId, so filter({ _id: req.params.id }) just works.
Querying (chaining)
all(), filter(), and exclude() return a lazy, chainable QuerySet that
executes only on a terminal operation. Because a QuerySet is awaitable,
await Model.objects.filter({...}) still resolves to the array — chaining is
purely additive.
// build up a query; nothing runs until it's awaited
const page = await Task.objects
.filter({ done: false })
.exclude({ priority__gte: 4 })
.order_by("-priority", "title") // `-` = descending
.offset(20)
.limit(10);
await User.objects.filter({ active: true }).first(); // one doc or null
await Task.objects.filter({ done: false }).exists(); // boolean
await Task.objects.filter({ done: false }).count(); // number
await Task.objects.filter({ done: false }).select("title", "priority"); // projection
// queryset-level writes use the chained filter:
await Task.objects.filter({ done: true }).update({ archived: true });
await Task.objects.exclude({ starred: true }).delete();| Chaining | Terminal |
| --- | --- |
| filter(where) · exclude(where) | await → documents |
| order_by(...fields) | first() → doc | null |
| limit(n) · offset(n) | exists() → boolean |
| select(...fields) | count() · get(where?) |
| populate(...refs) | update(patch) · delete() |
populate() hydrates ref fields — it replaces each id with the referenced
document (outer join, so documents with a missing ref are kept):
const [post] = await Post.objects.filter({ id }).populate("author");
post.author; // the full user document, not just an ObjectIdexclude() supports a single relational (ref) hop — e.g.
exclude({ author__name: "spam" }) removes posts whose author matches, while
keeping posts with no author (Django semantics). Multi-hop relational excludes
aren't supported yet.
Lifecycle hooks & transactions
Register pre/post hooks for create | update | delete. A pre hook can
mutate the payload or throw to abort:
User.pre("create", (doc) => { doc.email = doc.email.toLowerCase(); });
User.post("create", (user) => audit("user.created", user._id));
User.pre("delete", (ids) => audit("user.deleting", ids));Run several writes atomically with dorm.transaction (requires a replica set).
Pass the session to each write:
await dorm.transaction(async (session) => {
const user = await User.objects.create({ email }, { session });
await Account.objects.filter({ owner: user._id }).session(session).update({ active: true });
});Errors
DormValidationError— thrown bycreate/updateon invalid input. It collects all failing fields in.errors({ field, code, message, … }[]);.toJsonResponse()returns{ message, errors }. The top-level.field/.codemirror the first error. Codes:DORM_ERROR_1(required),DORM_ERROR_2(type),DORM_ERROR_3(customvalidatehook),DORM_ERROR_4(unique — an app-level check onuniquefields that complements the DB index).DoesNotExist/MultipleObjectsReturned— thrown byget.
Scripts
npm run build # emit dist/ (JS + .d.ts)
npm run typecheck # tsc --noEmit
npm test # vitest (unit + in-memory-Mongo integration)
npm run test:unit # pure unit tests only (no MongoDB needed)
npm run test:coverage # full suite with a coverage report (text + HTML + lcov)
npm run lint # eslintReleasing
Publishing is automated. On a push to main, the Publish workflow publishes
to npm only if package.json's version isn't already on the registry. To cut
a release:
- Bump the version (
npm version patch|minor|major). - Merge to
main.
The workflow runs prepublishOnly (typecheck → unit tests → build) before
publishing, and authenticates to npm via a trusted publisher (GitHub OIDC) —
no NPM_TOKEN secret required. Because the repo is public, npm generates
provenance attestations automatically on each publish.
Roadmap
Two parts of the type surface are intentionally loose today and are explicitly outside the 1.0 type-stability guarantee — they may be tightened in a minor release (see the versioning policy):
- Deep relational filter keys —
ref__…traversal values are accepted asunknown. Runtime behavior is fully supported; only the static types are loose. populate()result typing — the hydrated field is a generic object.
A fully-typed, opt-in schema-registry API (letting ref fields carry their
target type, so filter keys and populate() infer automatically) is planned as
an additive, post-1.0 feature.
See examples/ for a runnable Express app.
Contributing
Contributions are welcome — see CONTRIBUTING.md for setup, conventions, and the versioning policy. Notable changes are tracked in CHANGELOG.md.
