Skip to content

MongoDB

Experimental

The MongoDB adapter is experimental. APIs may change at any moment.

@atscript/mongo provides a MongoDB adapter for the Atscript DB abstraction layer. It translates annotation-driven CRUD operations into native MongoDB queries, with support for auto-increment fields, ObjectId handling, nested document storage, Atlas Search indexes, and raw aggregation pipelines.

Installation

bash
pnpm add @atscript/mongo @atscript/utils-db @atscript/typescript mongodb
bash
npm install @atscript/mongo @atscript/utils-db @atscript/typescript mongodb
bash
yarn add @atscript/mongo @atscript/utils-db @atscript/typescript mongodb

mongodb is a peer dependency (^6.17.0).

Plugin Configuration

Register the MongoPlugin in your atscript.config.mts to enable MongoDB-specific annotations and primitives (mongo.objectId, mongo.vector):

typescript
import { defineConfig } from '@atscript/core'
import ts from '@atscript/typescript'
import { MongoPlugin } from '@atscript/mongo/plugin'

export default defineConfig({
  rootDir: 'src',
  plugins: [ts(), MongoPlugin()],
})

This adds the @db.mongo.* annotation namespace and the mongo.objectId / mongo.vector primitive types.

Define Your Schema

Create a .as file with @db.table and @db.mongo.collection annotations:

atscript
// todo.as
@db.table 'todos'
@db.mongo.collection
export interface Todo {
    @meta.id
    @db.default.fn 'increment'
    id: number

    title: string

    description?: string

    @db.default.value 'false'
    completed: boolean

    @db.default.fn 'now'
    createdAt?: number.timestamp.created
}

The @db.mongo.collection annotation automatically injects a non-optional _id: mongo.objectId field if one is not explicitly defined. This means every document gets a MongoDB _id even when your logical primary key is a different field (like id above).

TIP

If you want to control the _id type, declare it explicitly. It must be string, number, or mongo.objectId:

atscript
@db.table 'users'
@db.mongo.collection
export interface User {
    _id: mongo.objectId
    email: string.email
}

Connect and Create Tables

The AsMongo class wraps a MongoDB connection and provides table/adapter access. It accepts either a connection string or an existing MongoClient instance:

typescript
import { AsMongo } from '@atscript/mongo'
import { Todo } from './schema/todo.as'

const mongo = new AsMongo('mongodb://localhost:27017/myapp')
const todos = mongo.getTable(Todo)

await todos.ensureTable()
await todos.syncIndexes()

ensureTable() creates the collection if it does not exist. syncIndexes() synchronizes indexes defined in annotations with the database -- it creates missing indexes and drops stale ones (those prefixed with atscript__ that no longer match the schema).

You can also access the underlying MongoAdapter directly:

typescript
const adapter = mongo.getAdapter(Todo)

Or use an existing MongoClient:

typescript
import { MongoClient } from 'mongodb'

const client = new MongoClient('mongodb://localhost:27017/myapp')
const mongo = new AsMongo(client)

CRUD Operations

The table API follows the same patterns as the generic DB abstraction layer. All examples below assume the todos table from the setup above.

Insert

typescript
// Single insert
const result = await todos.insertOne({
  title: 'Write documentation',
  completed: false,
})
// -> { insertedId: ObjectId('...') }
// id auto-incremented, createdAt defaults to Date.now()
// _id auto-generated as ObjectId

// Bulk insert
const bulk = await todos.insertMany([
  { title: 'First task' },
  { title: 'Second task' },
])
// -> { insertedCount: 2, insertedIds: [ObjectId('...'), ObjectId('...')] }
// id values auto-incremented in order (1, 2, ...)

Fields with @db.default.value or @db.default.fn are automatically applied before insertion. Fields marked @db.ignore are stripped. ObjectId _id fields are optional on insert (auto-generated by MongoDB).

Find

typescript
// Find one
const todo = await todos.findOne({
  filter: { completed: false },
  controls: {},
})

// Find many with sorting and pagination
const page = await todos.findMany({
  filter: { completed: false },
  controls: { $sort: { createdAt: -1 }, $limit: 10, $skip: 0 },
})

// Find by ID (tries _id first, then falls back to @meta.id fields)
const byId = await todos.findById(1)

// Count
const total = await todos.count({
  filter: { completed: true },
  controls: {},
})

See Queries & Filters for the full filter syntax.

Update

typescript
// Partial update by primary key
await todos.updateOne({ _id: someObjectId, completed: true })

// Update many by filter
await todos.updateMany(
  { completed: false },
  { completed: true },
)

Delete

typescript
// Delete by primary key
await todos.deleteOne({ _id: someObjectId })

// Delete many by filter
await todos.deleteMany({ completed: true })

_id and Primary Keys

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

  • @db.mongo.collection auto-injects _id: mongo.objectId if you don't declare one. The _id field is always non-optional.
  • @meta.id on a non-_id field does not make that field a MongoDB primary key. Instead, the adapter creates a unique index on it and registers it as a "unique property" for fallback lookups.
  • findById first tries _id, then falls back to fields marked with @meta.id. This means findById(1) works even when 1 is the auto-incremented id field rather than an ObjectId.
  • prepareId() 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 todos.findById(new ObjectId('507f1f77bcf86cd799439011'))  // by _id
await todos.findById('507f1f77bcf86cd799439011')                // string -> ObjectId
await todos.findById(42)                                         // by @meta.id field

Auto-Increment

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

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

How it works:

  1. On insertOne, the adapter reads the current maximum value for the field via a $group aggregation ({ $max: "$id" }).
  2. It assigns max + 1 to the field.
  3. On insertMany, values are batch-assigned in order -- the first item gets max + 1, the second gets max + 2, and so on.
  4. If a document already has an explicit value for the field, that value is kept and the running maximum is updated accordingly.

WARNING

Auto-increment uses no transactions or retries. It is simple and predictable for typical workloads, but concurrent inserts could produce duplicate values under high contention. For guaranteed uniqueness, combine @db.default.fn '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.fn 'increment'
    id: number

    name: string

    contact: {
        email: string
        phone?: string
    }

    preferences: {
        theme: string
        lang: string
    }
}
typescript
// Insert with nested objects -- stored natively in MongoDB
await users.insertOne({
  name: 'Alice',
  contact: { email: 'alice@example.com', phone: '555-0100' },
  preferences: { theme: 'dark', lang: 'en' },
})

// Query by nested path -- dot-notation works directly
const result = await users.findMany({
  filter: { 'contact.email': 'alice@example.com' },
  controls: { $sort: { 'preferences.theme': 1 } },
})

TIP

Since MongoDB handles nested objects natively, the @db.json annotation has no effect -- there is no flattening to override. You can still use it for documentation purposes, but it does not change storage behavior.

Accessing the Adapter

For operations beyond the standard CRUD interface, use table.getAdapter() to access the MongoAdapter directly. This gives you the raw MongoDB Collection and all driver methods:

typescript
const adapter = todos.getAdapter()

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

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

You can also get the adapter via AsMongo:

typescript
const adapter = mongo.getAdapter(Todo)
const collection = adapter.collection

See Also

Released under the ISC License.