Skip to content

MongoDB

The MongoDB adapter (@atscript/db-mongo) connects your .as models to MongoDB with native nested object storage, aggregation pipelines, Atlas Search, and vector search. It translates annotation-driven CRUD operations into native MongoDB queries while preserving the same AtscriptDbTable API used by all adapters.

Installation

bash
pnpm add @atscript/db-mongo mongodb

Register the MongoDB plugin in your atscript.config.mts to enable @db.mongo.* annotations and mongo.* primitives:

typescript
import { defineConfig } from "@atscript/core";
import ts from "@atscript/typescript";
import { dbPlugin } from "@atscript/db/plugin";
import { MongoPlugin } from "@atscript/db-mongo";

export default defineConfig({
  plugins: [ts(), dbPlugin(), MongoPlugin()],
});

dbPlugin() is required — it registers all portable @db.* annotations. See Setup for full configuration details.

Setup

Create a DbSpace with a MongoAdapter factory:

typescript
import { DbSpace } from "@atscript/db";
import { MongoAdapter } from "@atscript/db-mongo";
import { MongoClient } from "mongodb";

const client = new MongoClient("mongodb://localhost:27017");
const mongoDb = client.db("myapp");
const db = new DbSpace(() => new MongoAdapter(mongoDb, client));

The second constructor argument (client) enables transaction support. If you do not need transactions, new MongoAdapter(mongoDb) without the client is sufficient.

Optional mongodb peer deps

The mongodb driver declares several optional peers (e.g. aws4 for MONGODB-AWS, kerberos, mongodb-client-encryption) that pnpm won't install for you. If you hit MongoMissingDependencyError in production but not locally, see the mongodb optional dependencies docs — this is upstream, not an atscript-db concern.

Or use the convenience helper:

typescript
import { createAdapter } from "@atscript/db-mongo";

const db = createAdapter("mongodb://localhost:27017/myapp");

createAdapter creates a MongoClient (connection is lazy — established on first query), extracts the database from the connection string, and returns a ready-to-use DbSpace.

Once you have a DbSpace, get a table handle for any .as type:

typescript
import { User } from "./schema/user.as";

const users = db.getTable(User);
const user = await users.findById(1);

Run npx asc db sync to create or update collections and indexes. See Schema Sync for details.

Connection recipes

Local replica set (transactions enabled). MongoDB transactions require a replica set or sharded topology. The simplest local setup is a single-node replica set:

typescript
const client = new MongoClient("mongodb://localhost:27017/myapp?replicaSet=rs0");
const db = new DbSpace(() => new MongoAdapter(client.db(), client));

Single-node test container (directConnection). When pointing at a single-instance container that advertises itself under a different hostname (e.g., a Testcontainers MongoDB), set directConnection=true to skip topology discovery:

typescript
const client = new MongoClient("mongodb://localhost:27017/test?directConnection=true");

In-process MongoDB for tests (mongodb-memory-server). Spin up an ephemeral MongoDB inside the test process — no Docker required:

bash
pnpm add -D mongodb-memory-server
typescript
import { MongoMemoryServer } from "mongodb-memory-server";
import { MongoClient } from "mongodb";
import { DbSpace } from "@atscript/db";
import { MongoAdapter } from "@atscript/db-mongo";

const mongod = await MongoMemoryServer.create();
const client = new MongoClient(mongod.getUri());
const db = new DbSpace(() => new MongoAdapter(client.db("test"), client));

// after tests:
await client.close();
await mongod.stop();

For transaction support inside tests, use MongoMemoryReplSet.create() instead.

MongoDB-Specific Annotations

These annotations are available when the MongoDB plugin is registered. They extend the generic @db.* namespace with MongoDB-specific behavior.

AnnotationLevelPurpose
@db.mongo.collectionInterfaceMark as MongoDB collection, auto-inject _id
@db.mongo.capped size, max?InterfaceCapped collection with size limit
@db.mongo.search.dynamic analyzer?, fuzzy?InterfaceDynamic Atlas Search index
@db.mongo.search.static analyzer?, fuzzy?, indexName?, strategy?InterfaceNamed static Atlas Search index. Repeatable. indexName? defaults to "DEFAULT". strategy?: compound/autocomplete/text
@db.mongo.search.text analyzer?, indexName?FieldInclude field in a search index as a word-matched field. Repeatable. indexName? defaults to "DEFAULT"
@db.mongo.search.autocomplete indexName?, tokenization?, minGrams?, maxGrams?, foldDiacritics?, analyzer?FieldInclude field as a prefix/typeahead field (double-mapped as string)

All generic @db.* annotations (@db.table, @db.index.*, @db.default.*, @db.rel.*, @db.json, @db.search.vector, @db.search.filter, etc.) work with MongoDB as well. See the Annotations Reference for the full list.

Primitives

mongo.objectId

A string type constrained to 24-character hex strings matching the MongoDB ObjectId format. Used for _id fields. At runtime, the adapter converts these strings to native ObjectId instances automatically.

atscript
@db.table 'users'
@db.mongo.collection
export interface User {
    // _id: mongo.objectId is auto-injected by @db.mongo.collection
    name: string
}

db.vector

Vector embedding fields use the core db.vector primitive (number[], registered by dbPlugin()) — there is no Mongo-specific vector primitive. Pair it with @db.search.vector to declare a vector search index.

atscript
@db.search.vector 1536, 'dotProduct', 'embeddings_idx'
embedding: db.vector

Primary Keys & _id

MongoDB always uses _id as the document primary key. The adapter enforces this regardless of your schema:

  • Auto-injection@db.mongo.collection adds _id: mongo.objectId if not declared. The _id field is always non-optional.
  • Custom @meta.id fields — Marking a non-_id field with @meta.id does not make it a MongoDB primary key. Instead, the adapter creates a unique index on it and registers it for fallback lookups.
  • findById resolution — First tries _id, then falls back to fields marked with @meta.id. So findById(42) works when 42 is an auto-incremented id field rather than an ObjectId.
  • prepareId() conversion — Automatically converts string IDs to ObjectId instances (for mongo.objectId fields) or to numbers (for numeric _id fields), so you can pass string values from URL parameters directly.
typescript
// All of these work:
await users.findById(new ObjectId("507f1f77bcf86cd799439011")); // by _id
await users.findById("507f1f77bcf86cd799439011"); // string -> ObjectId
await users.findById(42); // by @meta.id field

ID types: ObjectId (default), string, or number.

Auto-Increment

The @db.default.increment annotation enables auto-increment behavior for numeric fields:

atscript
@meta.id
@db.default.increment
id: number

The adapter uses an __atscript_counters collection for atomic sequence allocation via findOneAndUpdate with $inc. Each counter is keyed by {collection}.{field}.

  • On insertOne, the counter is atomically incremented by 1 and the value is assigned.
  • On insertMany, the counter is incremented by the batch size to pre-allocate a range. Values are assigned in order.
  • If a document already has an explicit value for the field, that value is used as-is and no counter allocation occurs. Note: this does not advance the counter, so subsequent auto-incremented values may collide with manually provided ones. Pair with @db.index.unique to catch duplicates.

WARNING

Concurrent inserts under high contention could produce duplicate values in rare cases. For guaranteed uniqueness, combine @db.default.increment with @db.index.unique.

Nested Objects

Unlike relational databases where nested objects are flattened into __-separated columns, MongoDB stores nested objects natively. The adapter skips flattening entirely — nested JavaScript objects are passed through to MongoDB as-is and read back without reconstruction.

atscript
@db.table 'users'
@db.mongo.collection
export interface User {
    @meta.id
    @db.default.increment
    id: number

    name: string

    contact: {
        email: string
        phone?: string
    }
}

Dot-notation queries work directly:

typescript
const result = await users.findMany({
  filter: { "contact.email": "alice@example.com" },
  controls: { $sort: { "contact.phone": 1 } },
});

TIP

The @db.json annotation has no effect on MongoDB — there is no flattening to override. You can still use it for documentation purposes, but it does not change storage behavior.

Native Patch Pipelines

MongoDB uses aggregation pipelines for array patch operations instead of the read-modify-write cycle used by relational adapters. All five patch operators are supported:

  • $insert — Append items to an array
  • $remove — Remove items matching a condition
  • $update — Update matching items in place
  • $upsert — Update if exists, insert if not
  • $replace — Replace the entire array

This is transparent to your code — the same patch API works across all adapters, but MongoDB executes updates atomically on the server using $concatArrays, $filter, $map, and other aggregation operators.

See Patch Operations for the full API.

Native Relation Loading

The adapter uses MongoDB $lookup aggregation stages for TO, FROM, and VIA relations instead of issuing separate queries. This means relation loading happens in a single round-trip to the database.

  • TO relations$lookup with localField / foreignField
  • FROM relations — Reverse $lookup from the related collection
  • VIA relations — Two-stage $lookup through the junction collection

Relation controls ($sort, $limit, $filter) are applied as pipeline stages within the $lookup. Nested lookups (relations of relations) are supported.

See Relations for details.

Standard MongoDB text search uses the generic @db.index.fulltext annotation. This works on all MongoDB deployments — standalone, replica sets, and Atlas.

atscript
@db.table 'articles'
@db.mongo.collection
export interface Article {
    @meta.id _id: mongo.objectId

    @db.index.fulltext 'content_idx'
    title: string

    @db.index.fulltext 'content_idx', 2
    body: string
}

Fields sharing the same index name ('content_idx') form a composite text index. The optional second argument is a weight — here body has weight 2, making matches in it score twice as high as title (default weight 1).

Query with search():

typescript
const results = await articles.search("mongodb tutorial");

See Text Search for the full guide.

Atlas Search brings full-text search powered by Apache Lucene to your MongoDB collections. It supports fuzzy matching, language-aware analyzers, and custom scoring — but requires a MongoDB Atlas deployment.

@db.mongo.search.dynamic auto-indexes every string field in the collection:

atscript
@db.table 'products'
@db.mongo.collection
@db.mongo.search.dynamic 'lucene.english', 1
export interface Product {
    @meta.id _id: mongo.objectId
    title: string
    description: string
    category: string
}

Arguments:

  1. Analyzer — the Lucene analyzer to use (e.g., 'lucene.english')
  2. Fuzzy level — typo tolerance (0, 1, or 2)

All string fields are searchable immediately with no per-field annotations needed.

@db.mongo.search.static creates a named index where you control exactly which fields are searchable and which analyzer each uses:

atscript
@db.table 'products'
@db.mongo.collection
@db.mongo.search.static 'lucene.english', 0, 'product_search'
export interface Product {
    @meta.id _id: mongo.objectId

    @db.mongo.search.text 'lucene.english', 'product_search'
    title: string

    @db.mongo.search.text 'lucene.standard', 'product_search'
    description: string

    // Not included in the search index
    sku: string
    price: number
}

Arguments for @db.mongo.search.static:

  1. Default analyzer — fallback analyzer for the index
  2. Fuzzy level — query-time typo tolerance (see Fuzzy Search)
  3. Index name — identifies the index for queries (the $index control)
  4. Strategy — the query shape: compound (default), autocomplete, or text (see Match Strategy)

Each @db.mongo.search.text field can use a different analyzer while belonging to the same named index.

Autocomplete & Typeahead

A plain search index matches whole words — "art" will not match "Artem". For as-you-type matching, annotate the field with @db.mongo.search.autocomplete:

atscript
@db.table 'users'
@db.mongo.collection
@db.mongo.search.static 'lucene.english', 0, 'people'
export interface User {
    @meta.id _id: mongo.objectId

    @db.mongo.search.autocomplete 'people'
    username: string
}

This indexes the field as an Atlas autocomplete type and double-maps it as a plain string, so exact-word hits still rank. Now ?$search=art matches "Artem" as you type.

The tokenization argument picks how partial matching works:

tokenizationMatchesExample
edgeGram (default)prefix — start of a word"art""Artem"
nGramsubstring — inside a word"tem""Artem"
rightEdgeGramsuffix — end of a word"sev""Maltsev"

edgeGram (prefix) covers the typical search-box case at the lowest index cost; reach for nGram only when you need true mid-word matching (larger index, slower builds). Other arguments default to minGrams: 2, maxGrams: 15, foldDiacritics: true (so "cafe" matches "café").

Match Strategy

The strategy argument on @db.mongo.search.static locks how a term is matched against the index — there is no per-query mode switching:

strategyQuery shape
compound (default)Word match and prefix — exact-word hits rank above prefix hits. Falls back to plain word match when the index has no autocomplete field (so unset behaves like before).
autocompletePrefix/typeahead only — no word-match clause.
textWord matching only — ignores autocomplete tokenization.
atscript
@db.mongo.search.static 'lucene.english', 0, 'people_prefix', 'autocomplete'

strategy affects only the query — the Atlas index definition is identical regardless.

Search Variants

Each index encodes one behavior. To match the same field different ways, declare a second index and select it per request with $index — one field can join several indexes:

atscript
@db.table 'users'
@db.mongo.collection
@db.mongo.search.static 'lucene.english', 0, 'users_exact'                    // word match
@db.mongo.search.static 'lucene.english', 1, 'users_prefix', 'autocomplete'   // typeahead + fuzzy
export interface User {
    @meta.id _id: mongo.objectId

    @db.mongo.search.text 'lucene.english', 'users_exact'
    @db.mongo.search.autocomplete 'users_prefix'
    username: string
}

?$search=art → the first-declared index (users_exact, word match). ?$search=art&$index=users_prefix → the typeahead variant. Same data, two locked behaviors, no query-time modes.

Supported Analyzers

Atlas Search uses Apache Lucene analyzers. The plugin whitelists the following values:

AnalyzerDescription
lucene.standardGeneral-purpose tokenizer, lowercases, removes stop words
lucene.simpleLowercases and splits on non-letter characters
lucene.whitespaceSplits on whitespace only, no lowercasing
lucene.englishEnglish-specific with stemming ("running" matches "run")
lucene.frenchFrench stemming and stop words
lucene.germanGerman stemming and stop words
lucene.italianItalian stemming and stop words
lucene.portuguesePortuguese stemming and stop words
lucene.spanishSpanish stemming and stop words
lucene.chineseChinese tokenization
lucene.hindiHindi tokenization
lucene.bengaliBengali tokenization
lucene.russianRussian stemming and stop words
lucene.arabicArabic stemming and stop words

See the MongoDB Atlas docs for descriptions and tokenization rules.

The fuzzy parameter controls typo tolerance using Levenshtein edit distance, applied at query time to the search operator. Declare it on the index (@db.mongo.search.static/.dynamic) and it applies to every $search automatically:

  • 0 (default) — no fuzzy, exact tokens only
  • 1 — one edit allowed (e.g., "mango" matches "mongo")
  • 2 — two edits allowed (e.g., "saerch" matches "search")

Atlas only honors an edit distance of 1 or 2; 0 simply disables it. Higher values increase recall at the cost of precision — 1 is a good default.

Callers can override the declared value per request with the $fuzzy control: ?$search=mongo&$fuzzy=2 widens tolerance, ?$search=mongo&$fuzzy=0 disables it for that one request. See HTTP — Relations & Search.

Searching at Runtime

Both text indexes and Atlas Search use the same API:

typescript
// Basic search (uses the best available index)
const results = await table.search("search query", {});

// Search with filters and pagination
const { data, count } = await table.searchWithCount("query", {
  filter: { category: "tech" },
  controls: { $limit: 20, $skip: 0 },
});

// Target a specific named index
const results = await table.search("query", {}, "product_search");

MongoDB supports vector similarity search via Atlas $vectorSearch. Use the generic @db.search.vector annotation with the db.vector primitive:

atscript
@db.search.vector 1536, 'cosine', 'doc_vectors'
embedding: db.vector

@db.search.filter 'doc_vectors'
category: string

The adapter builds $vectorSearch aggregation pipelines from your schema. No subclassing or callbacks needed — pass a pre-computed embedding vector directly to vectorSearch().

See Vector Search for the full annotation reference, programmatic API, and HTTP access.

Index Priority

When multiple search indexes exist on a collection, the adapter selects the default in this order:

  1. Dynamic Atlas Search index (highest priority)
  2. Static Atlas Search index
  3. MongoDB text index (lowest priority)

You can always bypass the priority by passing an explicit index name to search().

Capped Collections

Capped collections have a fixed maximum size and maintain insertion order (FIFO). They are ideal for logs, event streams, and cache-like data. Once the collection reaches its size limit, the oldest documents are automatically removed.

atscript
@db.table 'logs'
@db.mongo.collection
@db.mongo.capped 10485760, 10000
@db.sync.method 'drop'
export interface LogEntry {
    message: string
    level: string
    @db.default.now
    timestamp: number.timestamp.created
}

The first argument is the maximum size in bytes (10 MB above), and the optional second argument is the maximum number of documents (10,000 above). Changing cap size requires collection recreation. Use @db.sync.method 'recreate' to preserve data — sync copies the collection server-side into a temporary collection (<name>__tmp_<timestamp>) via $out, drops the original, recreates it with the new options, then merges the temp data back via $merge and drops the temp collection. Use @db.sync.method 'drop' if data loss is acceptable (the collection is dropped and recreated empty).

WARNING

Capped collections do not support document deletion or updates that increase document size. They are append-only by design.

Transactions

MongoDB transactions require a replica set or mongos topology. On standalone instances, the adapter gracefully skips transactional wrapping — operations run normally without guarantees. See Transactions for usage and behavioral details.

Schema Sync Notes

MongoDB uses snapshot-based schema sync (Path B — no column introspection):

  • Collections are created on demand when first accessed
  • Schema sync creates and manages indexes only — there are no column-level migrations
  • Capped collection option drift (size/max changes) is detected and flagged
  • Standard indexes use the atscript__ prefix so sync only touches managed indexes
  • Atlas Search indexes are managed separately from standard MongoDB indexes
  • Unique indexes over optional fields are partial. A @db.index.unique that includes an optional field gets a partialFilterExpression restricting it to documents where the optional field is present — so many documents may lack the field while present values stay unique, matching SQL's NULLS DISTINCT behavior. Changing a field's optionality changes the filter, which drops and recreates the index on the next sync.

See Schema Sync for the full sync workflow.

Accessing the Adapter

For operations beyond the standard CRUD interface, access the underlying MongoAdapter to use native MongoDB driver methods:

typescript
const adapter = db.getAdapter(User) as MongoAdapter

// Run an aggregation pipeline
const cursor = adapter.collection.aggregate([
  { $match: { status: 'active' } },
  { $group: { _id: '$department', count: { $sum: 1 } } },
])
const results = await cursor.toArray()

// Use any MongoDB driver method
await adapter.collection.distinct('status')
await adapter.collection.bulkWrite([...])

You can also access the adapter through a table handle:

typescript
const users = db.getTable(User);
const adapter = users.getAdapter();
const collection = adapter.collection; // native MongoDB Collection

Limitations

  • FK constraints emulated — referential integrity is enforced in the generic layer, not by MongoDB itself
  • Atlas Search requires Atlas — not available on self-hosted MongoDB
  • Vector search requires Atlas M10+ — minimum tier for vector search indexes
  • No SQL views — MongoDB does not have SQL-style views; use aggregation pipelines instead
  • Transactions require replica set — standalone MongoDB instances cannot use transactions
  • Embeddings are external — pass pre-computed vectors to vectorSearch(), the adapter does not generate them
  • Atlas Search indexes build asynchronously — they may take a few seconds to become available after creation

Next Steps

Released under the MIT License.