The same schema and queries, on MongoDB
Most ORMs are either SQL tools or MongoDB tools. kastor treats MongoDB as one more adapter: you declare tables with table(), describe relations with one() and many(), and query with findMany, include, where and $transaction. Types are inferred from the schema as you type, with no generate step.
kastor ships no driver. Install the official mongodb package (5.x or newer, against MongoDB server 5.0 or newer), connect a MongoClient, and pass a Db to the mongodb adapter from kastor-orm/mongodb.
npm install kastor-orm mongodb
docker run -d --name mongo -p 27017:27017 mongo:7 --replSet rs0
docker exec mongo mongosh --quiet --eval "rs.initiate()"The last two commands start a single node replica set, which you want even in development because transactions depend on it. More on that below.
import { MongoClient } from 'mongodb'
import { kastor, one, many } from 'kastor-orm'
import { mongodb } from 'kastor-orm/mongodb'
import { users, posts } from './schema'
const client = new MongoClient(
process.env.MONGODB_URL ?? 'mongodb://localhost:27017/?replicaSet=rs0&directConnection=true',
)
await client.connect()
export const db = kastor({
adapter: mongodb(client.db('app')),
schema: { users, posts },
relations: ({ users, posts }) => ({
users: { posts: many(posts) },
posts: { author: one(users, { fields: ['authorId'], references: ['id'] }) },
}),
})The directConnection=true parameter matters for a container started this way: the replica set member advertises the container host name, which your machine cannot resolve, and a direct connection skips that discovery step. Against MongoDB Atlas, use the connection string from the Atlas console as is.
Collections from table(), and what changes about ids
Each table('posts', ...) maps to a collection with the same name, and each column maps to a document field. .map('db_name') renames the stored field while your code keeps the TypeScript name. A single column primary key is stored as _id, but your code still reads it under its own name, such as id. The value types you read back are the same as on SQL: Date for datetime, bigint for bigint (a 64 bit Long in the document), a string for decimal (stored as Decimal128), Uint8Array for bytes, the declared type for t.json(), and plain objects instead of driver documents.
Ids are where SQL habits need adjusting, and the adapter's ids option decides how t.id() and t.bigId() keys are generated. The default, mongodb(db), stores a BSON ObjectId and hands it to your code as a 24 character hex string. Because the adapter reports string ids, kastor types every t.id() key, and every column with .references() to one, as string in results, filters and create or update data. mongodb(db, { ids: 'uuid' }) generates random UUID strings instead. mongodb(db, { ids: 'int' }) allocates increasing integers from a _kastor_counters collection, so keys stay numbers exactly like on SQL, which helps when the same schema also runs on a relational database. You keep writing t.id() in every case; only the adapter option changes. Other integer columns stay numbers in all three modes.
MongoDB does not enforce foreign keys, so kastor emulates them from your schema. On delete it applies each key's onDelete itself: cascade removes the documents that point to the deleted ones, recursively, set null and set default update them, and restrict or no action refuse the delete with FOREIGN_KEY_VIOLATION while such documents exist. Before an insert, and before an update that sets a reference, kastor checks that the referenced document exists and fails with the same code otherwise. Some limits are worth stating plainly: onUpdate is not emulated, because keys stored as _id never change; documents written by other programs or by raw commands bypass all of this; and outside a transaction a concurrent delete can slip between the existence check and the write.
Relations through $lookup pipelines
MongoDB has no joins in the SQL sense, but the aggregation framework has $lookup, which pulls matching documents from another collection into each result. kastor compiles include trees to aggregation pipelines built on $lookup, with the nested where, orderBy and take applied inside each lookup. One root call runs one pipeline, so you never loop over parents to fetch children.
import { db } from './db/client'
const authors = await db.users.findMany({
where: {
role: 'admin',
email: { endsWith: '@example.com', mode: 'insensitive' },
posts: { some: { published: true } },
},
include: {
posts: { where: { published: true }, orderBy: { title: 'asc' }, take: 5 },
_count: { select: { posts: true } },
},
orderBy: { createdAt: 'desc' },
take: 20,
})The result is typed from the schema: authors[0].posts is an array of post rows, and authors[0]._count.posts is a number. Lookups need an index on the foreign field to stay fast, which is why the schema below indexes authorId.
Filters mapped to MongoDB operators
Every filter in the query API has a MongoDB counterpart. You write the portable form and the adapter produces the match stage:
| kastor filter | MongoDB operator |
|---|---|
equals, not | $eq, and $nin with null added so null fields never match, as in SQL |
in, notIn | $in, and $nin with null added |
lt, lte, gt, gte | $lt, $lte, $gt, $gte |
contains, startsWith, endsWith | regular expression with the input escaped |
mode: 'insensitive' | case insensitive regular expression, or $toLower comparisons for lt and gt |
AND, OR, NOT | $and, $or and a negated condition |
some, every, none, is, isNot | $lookup followed by a match on the joined documents |
Null handling follows SQL rather than plain MongoDB. kastor stores nullable columns as null when you leave them out, so a filter for null finds them, and not, notIn and NOT never match documents where the field is null or missing. Performance still follows MongoDB: a regular expression anchored at the start can use an index, while contains and case insensitive matches usually scan, so pair them with other selective filters on large collections.
Raw SQL is not available on MongoDB. The sql tag inside where, $sql and $execute fail with UNSUPPORTED, because there is no SQL to run. The adapter's executor instead accepts database commands written as Extended JSON, with numbered parameter placeholders, which is also what migrations use. For anything larger, such as geospatial or text search stages, you already hold the Db instance and can call the driver directly.
import { MongoClient } from 'mongodb'
import { mongodb } from 'kastor-orm/mongodb'
const client = new MongoClient(process.env.MONGODB_URL!)
const adapter = mongodb(client.db('app'))
const email = 'ada@example.com'
const result = await adapter.executor.query(
'{"find": "users", "filter": {"email": {"$param": 1}}}',
[email],
)
console.log(result.rows)Transactions need a replica set
MongoDB supports multi document transactions only on replica sets and sharded clusters. On a standalone server, starting one fails with an error saying transaction numbers are only allowed on a replica set member. Atlas clusters are replica sets already, so this mostly bites in local development and CI, and a single node replica set like the one above solves it.
import { db } from './db/client'
export async function archiveDrafts(userId: string) {
return db.$transaction(async (tx) => {
const drafts = await tx.posts.findMany({ where: { authorId: userId, published: false } })
await tx.posts.deleteMany({ where: { authorId: userId, published: false } })
await tx.users.update({ where: { id: userId }, data: { role: 'user' } })
return drafts.length
}, { isolationLevel: 'serializable' })
}The callback form runs every operation in one client session with readConcern: majority and writeConcern: majority, commits when the callback resolves and aborts if it throws. Passing isolationLevel: 'repeatable read' or 'serializable' switches the read concern to snapshot. The array form, db.$transaction([a, b]), works the same way for a fixed list. MongoDB may abort a transaction on a write conflict; kastor surfaces that as a KastorError with code SERIALIZATION_FAILURE, and the fix is to retry the whole callback. Keep transactions short, since MongoDB limits how long one may stay open.
You do not need a transaction just to delete a user and their posts. With onDelete: 'cascade' on the reference, as in the schema below, db.users.delete removes the posts too, and kastor runs the cascade in its own transaction when the server supports one. On a standalone server single queries still work and cascades run without a transaction, but $transaction fails with the driver's error.
Migrations as JSON commands, validators and indexes
MongoDB has no SQL DDL, but kastor still runs migrations on it. kastor migrate dev diffs the schema against the database and writes a migration.sql file whose statements are JSON database commands, one per statement, kept in _kastor_migrations like on SQL. Creating a table creates the collection with a $jsonSchema validator: one property per column with its BSON type, maxLength for t.string(n), the allowed values for enums, and every non nullable column marked required. Indexes from index() and uniqueIndex() in the table options, plus .unique() on single columns, become createIndexes commands.
import { table, t, index } from 'kastor-orm'
export const users = table('users', {
id: t.id(),
email: t.string(255).unique(),
role: t.enum(['user', 'admin']).default('user'),
createdAt: t.datetime().defaultNow(),
})
export const posts = table('posts', {
id: t.id(),
title: t.string(200),
published: t.boolean().default(false),
authorId: t.int().references(() => users.id, { onDelete: 'cascade' }),
createdAt: t.datetime().defaultNow(),
}, { indexes: (p) => [index(p.authorId), index(p.published, p.createdAt)] })With the default ObjectId ids, users.id and posts.authorId are both typed as string even though the schema says t.id() and t.int(); with ids: 'int' they are numbers. A unique index is your only uniqueness guarantee in MongoDB, and a duplicate insert arrives as UNIQUE_VIOLATION, the same code you handle on SQL. A write that breaks the validator arrives as NOT_NULL_VIOLATION or CHECK_VIOLATION.
Changing columns is where MongoDB differs. Adding a column fills the new field on existing documents with its default, or null, and updates the validator; dropping one updates the validator and unsets the field; renaming uses $rename. Changing a column's type only updates the validator, so existing documents keep their old values until you rewrite them; plan those backfills as scripts. Collection and index commands cannot run inside a transaction, so a failed migration can leave earlier commands applied. For an existing database, kastor db pull reads each collection's validator and indexes, or samples documents when there is no validator. The migrations guide covers the workflow.
When to pick MongoDB
MongoDB fits data that is naturally document shaped: nested settings, event payloads, content with varying fields, or records that are almost always read whole. It also fits teams already running Atlas, where operations and backups are taken care of.
A relational database is the better default when integrity across tables matters more than flexibility: foreign keys enforced by the database for every client rather than emulated by kastor for its own writes, check constraints, ad hoc reporting with joins and aggregates. Because kastor keeps one API across both, you can start on one and move later, or run both side by side, as the multi-database guide shows. Compare the generated queries in the playground before you decide.
A practical test: write down the five queries your application runs most often. If each one reads a single document and its close relations, MongoDB with $lookup includes serves them well. If most of them join three or more tables, filter on relations, or aggregate across many rows for reports, a SQL database will express them more directly and enforce the rules your data depends on. Many teams end up with both, using MongoDB for flexible event or content data and a relational database for accounts and billing.
Questions people ask
Q1Can I use the same TypeScript schema for MongoDB and PostgreSQL?
Yes. kastor schemas are plain TypeScript built with table() and t, and the MongoDB adapter reads the same definitions. Queries, includes and transactions keep the same API, so most application code does not change between the two.
Q2Why do MongoDB transactions fail with a replica set error?
MongoDB only supports multi document transactions on a replica set or a sharded cluster. A standalone server rejects them. Locally, start mongod with --replSet rs0 and run rs.initiate() once to get a single node replica set.
Q3How does kastor load relations in MongoDB?
Includes compile to an aggregation pipeline with $lookup stages, so a findMany with nested includes runs as one pipeline per root call instead of one query per document.
Q4Does kastor have migrations for MongoDB?
Yes. kastor migrate dev writes a migration file that holds one JSON database command per statement: collections with a $jsonSchema validator built from your columns, and createIndexes commands for indexes and unique columns. kastor db pull reads validators and indexes back, or samples documents when a collection has no validator.
Q5Are ids strings or numbers on MongoDB?
By default mongodb(db) stores t.id() keys as ObjectIds and your code sees them as 24 character hex strings, so the keys and every column that references them are typed as string. Pass mongodb(db, { ids: 'uuid' }) for random UUID strings, or mongodb(db, { ids: 'int' }) for increasing integers typed as numbers, like on SQL.
Q6Is kastor a replacement for Mongoose?
They make different trade-offs. Mongoose is a MongoDB ODM with document instances, middleware and validation hooks. kastor returns plain typed objects and keeps one schema and query API across SQL databases and MongoDB.