@livequery/mongodb
v2.0.155
Published
MongoDB datasource mapping for @livequery ecosystem
Readme
@livequery/mongodb
Native MongoDB datasource adapter for the @livequery ecosystem.
This package translates Livequery request shapes into MongoDB native driver operations. It is intended for projects that want to use @livequery/core with plain mongodb collections, without Mongoose models or schema introspection.
Integration with @livequery/core: new MongoDatasource(config), init(routes), then handle(ctx). For imperative use you can also call query(req, options) directly.
Reads are executed with MongoDB aggregation pipelines through Collection.aggregate(...).toArray(). Writes use native collection methods such as insertOne, updateOne, and deleteOne.
Installation
bun add @livequery/mongodb mongodb rxjsFor local development in this workspace, @livequery/core is installed as a dev dependency from file:../core. Runtime JavaScript does not import @livequery/core; the generated declaration files use core types.
Exports
export * from './MongoDatasource.js'
export * from './DataChangePayload.js'
export * from './MongodbRealtime.js'
export * from './MongodbCollection.js'Project Meaning
@livequery/mongodb is the MongoDB datasource layer for Livequery.
Its job is not to parse HTTP requests. That belongs to @livequery/core, usually through LivequeryRequestParser. Its job is also not to provide Mongoose-style schemas, validation, hooks, virtuals, or populate(). This package receives a parsed Livequery request, resolves which MongoDB collection should handle it, and runs the corresponding native MongoDB operation.
Typical request flow with @livequery/core:
- A framework adapter creates a
LivequeryContext. LivequeryRequestParserreadsctx.requestand writesctx.livequery.MongoDatasource.handle(ctx)resolves route options fromctx.request.methodandctx.request.ref.MongoDatasourcereadsctx.livequery(keys, query, body, method) as the adapter request.- Reads are delegated to
MongoQuery; writes go directly to the native collection. - The result is assigned to
ctx.response.
Main Classes And Types
MongoDatasource
Main adapter class.
class MongoDatasource extends Subject<UpdatedData<LivequeryBaseEntity>>Responsibilities:
- Store datasource config and route options.
- Initialize from a list of routes.
- Resolve connection, database, and collection for each request.
- Normalize configured ObjectId fields.
- Execute reads, inserts, updates, and deletes.
- Implement
handle(ctx)for@livequery/core.
constructor(config?)
Creates a datasource.
Parameters:
config?: MongoDatasourceConfig: optional database configuration. Use this for core-style initialization.
Example:
const datasource = new MongoDatasource({
connections: { default: client },
databases: ['main'],
})config is required before any request runs. Omit it here only if you assign datasource.config before the first handle / query call.
init(routes)
Core-style initialization.
Parameters:
routes: Array<LivequeryDatasourceInitConfig<RouteOptions>>: route entries. Each entry includesmethod,path, and route options such ascollection,db,connection, andobjectIdFields.
Example:
await datasource.init([
{
method: 'GET',
path: '/products',
collection: 'products',
},
])Route lookup uses METHOD path, for example GET /products. A path-only fallback is also stored for compatibility.
handle(ctx)
Core handler entry point.
Parameters:
ctx: LivequeryContext: context created by@livequery/coreor a framework adapter.ctx.livequerymust already be populated, usually byLivequeryRequestParser.
Behavior:
- Throws
INVALID_LIVEQUERY_REQUESTifctx.livequeryis missing. - Resolves route options from
ctx.request.methodandctx.request.ref || ctx.request.path. - Calls
query(req, options). - Assigns the result to
ctx.response. - Returns
ctx.response.
Example:
new LivequeryRequestParser().handle(ctx)
await datasource.handle(ctx)
console.log(ctx.response)Important: for dynamic routes such as /products/:id, ctx.request.ref should be the route pattern, not the concrete URL. Example: ref: '/products/:id', path: '/products/507f1f77bcf86cd799439011'.
query(req, options)
Executes a parsed Livequery request against one MongoDB collection.
Parameters:
req: LivequeryRequest: parsed Livequery request. Readsreq.keys,req.query,req.method, andreq.body.options: RouteOptions: route configuration that tells the adapter which collection, database, connection, and ObjectId fields to use.
Supported req.method values:
get: read collection or document.post: insert one document.put: update one document.patch: update one document.delete: delete one document.
Write behavior:
postmergesreq.keysandreq.body, then callsinsertOne.putandpatchcallupdateOne.- Plain update bodies are wrapped in
$set. - Bodies that already contain MongoDB update operators, such as
$setor$inc, are passed through unchanged. deletecallsdeleteOne.
Example:
const response = await datasource.query(
{
method: 'get',
ref: 'products',
is_collection: true,
keys: {},
query: { ':limit': 10 },
},
{
collection: 'products',
}
)MongoQuery
Static read query builder.
Responsibilities:
- Convert Livequery filters into MongoDB aggregation stages.
- Build sort stages from
field:sortoptions. - Build cursor paging stages from
:after,:before, and:around. - Convert Mongo
_idinto responseid. - Parse summary aggregation options beginning with
::. - Execute
collection.aggregate(pipeline).toArray().
MongoQuery.query(req, collection)
Parameters:
req: LivequeryRequest: parsed adapter request. Readsreq.keys,req.query, andreq.is_collection(req.queryis normalized to{}when absent).collection: Collection<T>: native MongoDB collection.
Behavior:
- For document reads, matches by
req.keys, convertsreq.keys.idto_id, renames_idtoid, and returns a one-item result shape. - For collection reads, builds an aggregation pipeline with sort, filter, search, id rename, cursor paging, and summary facets.
Known behavior:
:limitdefaults to10.- Minimum
:limitis1. - Maximum
:limitis100. - Cursor paging (
:after/:before/:around) is the default. - Offset paging is used when
:pageis provided.
Filter examples:
{
'status': 'active',
'price:gte': 10,
'price:lte': 100,
'categoryId:eq-oid': '507f1f77bcf86cd799439011',
'name:like': 'phone',
':limit': 20,
'price:sort': 'asc',
}Summary example:
{
'category:sort': 'asc',
'::totals': 'category|sum(price)|avg(price)|count()',
}Cursor
Cursor pagination helper.
Cursor.caculate(item, options)
Builds a cursor from a response item and active sort options.
Parameters:
item: LivequeryBaseEntity: response item. Must containid.options: Record<string, any>: the request query (req.query). Sort options ending with:sortare included in the cursor.
Returns:
- Hex-encoded JSON cursor string.
nullwhenitemis missing.
The method name is intentionally spelled caculate for compatibility.
Cursor.parse(cursor)
Decodes a cursor.
Parameters:
cursor: string: hex-encoded JSON cursor created byCursor.caculate.
Returns:
- Parsed cursor object.
nullwhen the input is empty.
SmartCache
Small async promise cache used for native collection handles.
get(key, resolver)
Parameters:
key: any: cache key.resolver: () => Promise<T>: async function used when the key is not already cached.
Returns:
- The cached promise result.
The collection cache key includes connection, database, and collection name to avoid reusing collection handles across tenants or connections.
MongodbRealtime
MongoDB change stream watcher for realtime Livequery updates. This replaces the need to use the separate @livequery/mongodb-mapper package in native MongoDB projects.
import { WebsocketGateway } from '@livequery/core'
import { MongoDatasource, MongodbRealtime } from '@livequery/mongodb'
const datasource = new MongoDatasource({
connections: { default: client },
databases: ['main'],
})
await datasource.init([
{
method: 'GET',
path: '/products',
collection: 'products',
realtime: true,
},
])
const websocketGateway = new WebsocketGateway(server)
new MongodbRealtime()
.watch(datasource.config, [
{
// LivequeryRequestParser.parse(...).schema — document-id segment already stripped
schema: 'products',
options: { collection: 'products', realtime: true },
},
])
.subscribe(websocketGateway)MongoRealtimeRoute:
type MongoRealtimeRoute = {
schema: string // parsed route path from @livequery/core, e.g. 'users/:userId/posts'
options: RouteOptions
}Realtime route requirements:
realtimemust betrue.schemais the parsed route path (LivequeryRequestParser.parse(...).schema), so the document-id segment is already stripped and each:paramnames the document field holding the parent value.collectionmust be a static string. Dynamic collection, database, or connection resolver functions are skipped because database watchers must be known up front.- When watching a
MongoClient,dborconfig.databasesdecides which database names to watch. When watching aDb, that database is used directly.
By default, MongodbRealtime enables MongoDB pre/post images with collMod and watches with fullDocument: 'updateLookup' and fullDocumentBeforeChange: 'whenAvailable'.
Disable the collMod call when your deployment manages pre/post images separately:
new MongodbRealtime({ enablePreAndPostImages: false })For nested collection refs, name the route param after the document field that holds the parent value:
{
schema: 'users/:userId/posts',
options: { collection: 'posts', realtime: true },
}If the document field is an array (one document belongs to many parents), the change is fanned out to one ref per array element, and array membership changes emit added/removed per ref.
An inserted { _id: 'post1', userId: 'user1', title: 'Hello' } emits:
{
ref: 'users/user1/posts',
type: 'added',
data: { id: 'post1', userId: 'user1', title: 'Hello' },
}MongodbCollection
Lightweight imperative CRUD wrapper over a native MongoDB collection, with a Mongoose-Model-like surface. It is independent of the Livequery request flow and LivequeryContext: reach for it when application or service code needs to read and write documents directly, rather than through MongoDatasource.handle(ctx).
It is intentionally not an ODM. There are no schemas, validation, hooks, virtuals, or populate(). It only layers a few conveniences over the native driver: id / _id normalization, a collection-level defaults resolver, automatic timestamps, and flexible single-document filters.
class MongodbCollection<T = any>Responsibilities:
- Wrap one collection, resolved lazily from a provided
Db. - Return documents with an enumerable
id: stringand a hidden_id. - Apply a defaults resolver plus
created_at/updated_aton insert. - Bump
updated_aton every update. - Accept a
stringid, anObjectId, or a filter object for single-document operations.
defineCollection<T>(config) and constructor(db, config)
A collection is described once with defineCollection, then bound to a Db instance.
defineCollection<T>({ collection, defaults? }) returns a typed CollectionDef<T>:
collection: string: collection name. The handle is resolved lazily viadb.collection(name).defaults?: (input: Partial<T>) => Partial<T>: optional default-field resolver, a replacement for Mongoose@Prop({ default }). It runs on everycreate/insertManywith the input document; the input always overrides the returned defaults.
new MongodbCollection<T>(db, config):
db: Db: a connectedmongodbDb. It is passed in explicitly (no hidden module singleton), so one class works across databases and connections.config: CollectionDef<T>: the definition returned bydefineCollection. The genericTis inferred from it, so the instance is fully typed.
Example:
import { MongoClient } from 'mongodb'
import { MongodbCollection, defineCollection } from '@livequery/mongodb'
const client = new MongoClient(process.env.MONGO_URL!)
await client.connect()
const db = client.db('main')
type Order = { video_id: string; amount: number; started: boolean; running: boolean }
type Video = { title: string }
// With a defaults resolver (replaces Mongoose @Prop({ default })):
const Orders = new MongodbCollection(db, defineCollection<Order>({
collection: 'orders',
defaults: () => ({ started: false, running: true }),
}))
// Without defaults:
const Videos = new MongodbCollection(db, defineCollection<Video>({ collection: 'videos' }))Define each collection once at composition time and reuse the instance across the app.
Document shape: MongoDoc<T>
type MongoDoc<T> = T & { id: string; toJSON(): any }Returned documents are hydrated:
idis an enumerable string (_id.toString()), so it appears inJSON.stringify, JSON responses, and{ ...doc }._idis non-enumerable, so it never leaks into output, yetdoc._idis still readable as anObjectIdinternally.toJSON()returns the document without_idand__v.
Methods
| Method | Description |
| --- | --- |
| find(filter?) | Returns hydrated documents. |
| findOne(filter?) | filter may be a string id, an ObjectId, or a filter object. |
| findById(id) | Shorthand for findOne with a string id or ObjectId. |
| create(doc) | Inserts one document; applies defaults + timestamps; strips any incoming id / _id. |
| insertMany(docs) | Inserts many documents with the same preparation as create. |
| updateOne(filter, update, opts?) | Wraps plain updates in $set and bumps updated_at; filter accepts string id / ObjectId / object. |
| updateMany(filter, update, opts?) | Same update handling for many documents. |
| deleteOne(filter) | Deletes one; filter accepts string id / ObjectId / object. |
| deleteMany(filter?) | Deletes many documents. |
| countDocuments(filter?) | Counts matching documents. |
| exists(filter?) | true when at least one document matches. |
| aggregate(pipeline) | Runs an aggregation pipeline and returns the array. |
| collection | Getter for the raw native Collection (escape hatch). |
Behavior:
- Filter normalization: a 24-hex
stringbecomes{ _id: ObjectId }; anObjectIdbecomes{ _id }; any other string or object is used unchanged. - Update normalization: an update that already contains a
$-operator (such as$incor$push) is passed through; otherwise it is wrapped in$set.updated_atis always merged into the$setbranch. - Inserts never persist an incoming
idor_id.
Example:
// create — applies defaults (started/running) + created_at/updated_at; strips any client id/_id
const order = await Orders.create({ video_id: 'v1', amount: 50 })
order.id // '507f1f77bcf86cd799439011'
order.started // false — from the defaults resolver
order._id // ObjectId — still readable internally
JSON.stringify(order) // contains "id", not "_id"
// insertMany — same preparation as create, returns hydrated docs
const [a, b] = await Orders.insertMany([{ video_id: 'v2', amount: 10 }, { video_id: 'v3', amount: 20 }])
// reads — single-doc helpers accept a string id, an ObjectId, or a filter object
await Orders.findOne('507f1f77bcf86cd799439011') // by string id (24-hex → _id)
await Orders.findById(order._id) // by ObjectId
await Orders.findOne({ video_id: 'v1' }) // by filter
await Orders.find({ started: false }) // many
// updates — plain bodies are wrapped in $set; operator bodies pass through; updated_at always bumped
await Orders.updateOne(order.id, { amount: 80 }) // → { $set: { amount: 80, updated_at } }
await Orders.updateOne(order.id, { $inc: { amount: 5 } }) // → { $inc, $set: { updated_at } }
await Orders.updateMany({ started: false }, { running: true })
// existence / counting
await Orders.exists(order.id) // boolean
await Orders.countDocuments({ video_id: 'v1' }) // number
// delete
await Orders.deleteOne(order.id)
await Orders.deleteMany({ video_id: 'v3' })
// escape hatches
await Orders.aggregate([{ $group: { _id: '$video_id', total: { $sum: '$amount' } } }])
Orders.collection // raw native CollectionDataChangePayload<T>
Type-only realtime/change payload contract.
type DataChangePayload<T = any> = {
id: string
type: 'added' | 'modified' | 'removed'
data: T
refs: Array<{ ref: string, old_ref: string }>
new_doc: T
}Configuration Types
MongoDatasourceConfig
import type { Db, MongoClient } from 'mongodb'
type MongoConnection = MongoClient | Db
type MongoDatasourceConfig = {
connections: { [key: string]: MongoConnection }
databases?: string[]
}Fields:
connections: map of connection names to eitherMongoClientorDb.databases: optional list of database names. This is metadata for consumers; collection resolution uses routedb,process.env.DB_NAME, or"main".
Default resolution:
- Connection defaults to the first configured connection name, then
"default". - Database defaults to route
db, thenprocess.env.DB_NAME, then"main". - If the connection is a
MongoClient, the datasource callsclient.db(dbName). - If the connection is already a
Db, thatDbis used directly.
RouteOptions
type RouteOptions = {
realtime?: boolean
collection: string | ((req: LivequeryRequest) => Promise<string> | string)
db?: string | ((req: LivequeryRequest) => Promise<string> | string)
connection?: string | ((req: LivequeryRequest) => Promise<string> | string)
objectIdFields?: string[]
}Fields:
realtime: marks a static collection route forMongodbRealtime.watch(). Query execution itself is unchanged.collection: required collection name or resolver function.db: optional database name or resolver function.connection: optional connection name or resolver function.objectIdFields: top-level request fields that should be converted from valid string ids toObjectId.
Use function values when tenant, database, or collection depends on request keys.
Core Usage Example
import { MongoClient } from 'mongodb'
import { LivequeryRequestParser, type LivequeryContext } from '@livequery/core'
import { MongoDatasource } from '@livequery/mongodb'
const client = new MongoClient(process.env.MONGO_URL!)
await client.connect()
const datasource = new MongoDatasource({
connections: { default: client },
databases: ['main'],
})
await datasource.init([
{
method: 'GET',
path: '/products',
collection: 'products',
},
{
method: 'GET',
path: '/products/:id',
collection: 'products',
},
])
const ctx: LivequeryContext = {
request: {
method: 'GET',
path: '/products',
ref: '/products',
params: {},
query: { ':limit': 20, 'price:sort': 'desc' },
headers: new Map(),
},
}
new LivequeryRequestParser().handle(ctx)
await datasource.handle(ctx)
console.log(ctx.response)Core Document Route Example
const ctx: LivequeryContext = {
request: {
method: 'GET',
path: '/products/507f1f77bcf86cd799439011',
ref: '/products/:id',
params: { id: '507f1f77bcf86cd799439011' },
query: {},
headers: new Map(),
},
}
new LivequeryRequestParser().handle(ctx)
await datasource.handle(ctx)LivequeryRequestParser will set ctx.livequery.document_id and ctx.livequery.keys.id. The datasource converts id to Mongo _id for document reads and writes.
Direct Query Example
For imperative use, call query(req, options) directly without going through handle(ctx). Pass the config to the constructor; init(routes) is not required for this path since the collection is given in options.
import { MongoClient } from 'mongodb'
import { MongoDatasource } from '@livequery/mongodb'
const client = new MongoClient(process.env.MONGO_URL!)
await client.connect()
const datasource = new MongoDatasource({
connections: { default: client },
databases: ['main'],
})
const response = await datasource.query(
{
method: 'get',
ref: 'products',
is_collection: true,
keys: {},
query: { ':limit': 10 },
},
{
collection: 'products',
}
)
console.log(response.items)Dynamic Tenant Example
await datasource.init([
{
method: 'GET',
path: '/tenant/:tenantId/products',
connection: req => req.keys.tenantId,
db: req => `tenant_${req.keys.tenantId}`,
collection: req => `products_${req.keys.tenantId}`,
objectIdFields: ['ownerId', 'categoryId'],
},
])This lets one datasource choose connection, database, and collection per request.
ObjectId Handling
This package does not use Mongoose schema introspection. Configure ObjectId conversion explicitly.
Use objectIdFields for top-level keys and write bodies:
await datasource.init([
{
method: 'PATCH',
path: '/products/:id',
collection: 'products',
objectIdFields: ['ownerId', 'categoryId'],
},
])Use query suffixes for filter values:
{
'ownerId:eq-oid': '507f1f77bcf86cd799439011',
}Build And Verification
npm run build
npm testnpm test runs the Bun test suite. The tests use mocked MongoDB collections, so they do not require a real MongoDB server.
Notes
- This package is ESM and uses TypeScript
NodeNext. - Local imports in source files should include
.jsextensions. - Do not add Mongoose dependencies here. Mongoose-specific behavior belongs in
@livequery/mongoose. @livequery/coreis used for types and core handler integration.
