Thin MongoDB ODM built for Standard Schema
mongodb
zod
deno
1import type { z } from "@zod/zod";
2import type {
3 Collection,
4 CreateIndexesOptions,
5 DeleteResult,
6 Document,
7 DropIndexesOptions,
8 Filter,
9 IndexDescription,
10 IndexSpecification,
11 InsertManyResult,
12 InsertOneResult,
13 InsertOneOptions,
14 FindOptions,
15 UpdateOptions,
16 ReplaceOptions,
17 DeleteOptions,
18 CountDocumentsOptions,
19 AggregateOptions,
20 ListIndexesOptions,
21 UpdateResult,
22 WithId,
23 BulkWriteOptions,
24} from "mongodb";
25import type { ObjectId } from "mongodb";
26import { getDb } from "../client/connection.ts";
27import type { Schema, Infer, Input, Indexes, ModelDef } from "../types.ts";
28import * as core from "./core.ts";
29import * as indexes from "./indexes.ts";
30import * as pagination from "./pagination.ts";
31
32/**
33 * Model class for type-safe MongoDB operations
34 *
35 * Provides a clean API for CRUD operations, pagination, and index management
36 * with automatic Zod validation and TypeScript type safety.
37 *
38 * @example
39 * ```ts
40 * const userSchema = z.object({
41 * name: z.string(),
42 * email: z.string().email(),
43 * });
44 *
45 * const UserModel = new Model("users", userSchema);
46 * await UserModel.insertOne({ name: "Alice", email: "alice@example.com" });
47 * ```
48 */
49export class Model<T extends Schema> {
50 private collection: Collection<Infer<T>>;
51 private schema: T;
52 private indexes?: Indexes;
53
54 constructor(collectionName: string, definition: ModelDef<T> | T) {
55 if ("schema" in definition) {
56 this.schema = definition.schema;
57 this.indexes = definition.indexes;
58 } else {
59 this.schema = definition as T;
60 }
61 this.collection = getDb().collection<Infer<T>>(collectionName);
62
63 // Automatically create indexes if they were provided
64 if (this.indexes && this.indexes.length > 0) {
65 // Fire and forget - indexes will be created asynchronously
66 indexes.syncIndexes(this.collection, this.indexes)
67 }
68 }
69
70 // ============================================================================
71 // CRUD Operations (delegated to core.ts)
72 // ============================================================================
73
74 /**
75 * Insert a single document into the collection
76 *
77 * @param data - Document data to insert
78 * @param options - Insert options (including session for transactions)
79 * @returns Insert result with insertedId
80 */
81 async insertOne(
82 data: Input<T>,
83 options?: InsertOneOptions
84 ): Promise<InsertOneResult<Infer<T>>> {
85 return await core.insertOne(this.collection, this.schema, data, options);
86 }
87
88 /**
89 * Insert multiple documents into the collection
90 *
91 * @param data - Array of document data to insert
92 * @param options - Insert options (including session for transactions)
93 * @returns Insert result with insertedIds
94 */
95 async insertMany(
96 data: Input<T>[],
97 options?: BulkWriteOptions
98 ): Promise<InsertManyResult<Infer<T>>> {
99 return await core.insertMany(this.collection, this.schema, data, options);
100 }
101
102 /**
103 * Find multiple documents matching the query
104 *
105 * @param query - MongoDB query filter
106 * @param options - Find options (including session for transactions)
107 * @returns Array of matching documents
108 */
109 async find(
110 query: Filter<Infer<T>>,
111 options?: FindOptions
112 ): Promise<(WithId<Infer<T>>)[]> {
113 return await core.find(this.collection, query, options);
114 }
115
116 /**
117 * Find a single document matching the query
118 *
119 * @param query - MongoDB query filter
120 * @param options - Find options (including session for transactions)
121 * @returns Matching document or null if not found
122 */
123 async findOne(
124 query: Filter<Infer<T>>,
125 options?: FindOptions
126 ): Promise<WithId<Infer<T>> | null> {
127 return await core.findOne(this.collection, query, options);
128 }
129
130 /**
131 * Find a document by its MongoDB ObjectId
132 *
133 * @param id - Document ID (string or ObjectId)
134 * @param options - Find options (including session for transactions)
135 * @returns Matching document or null if not found
136 */
137 async findById(
138 id: string | ObjectId,
139 options?: FindOptions
140 ): Promise<WithId<Infer<T>> | null> {
141 return await core.findById(this.collection, id, options);
142 }
143
144 /**
145 * Update multiple documents matching the query
146 *
147 * @param query - MongoDB query filter
148 * @param data - Partial data to update
149 * @param options - Update options (including session for transactions)
150 * @returns Update result
151 */
152 async update(
153 query: Filter<Infer<T>>,
154 data: Partial<z.infer<T>>,
155 options?: UpdateOptions
156 ): Promise<UpdateResult<Infer<T>>> {
157 return await core.update(this.collection, this.schema, query, data, options);
158 }
159
160 /**
161 * Update a single document matching the query
162 *
163 * @param query - MongoDB query filter
164 * @param data - Partial data to update
165 * @param options - Update options (including session for transactions)
166 * @returns Update result
167 */
168 async updateOne(
169 query: Filter<Infer<T>>,
170 data: Partial<z.infer<T>>,
171 options?: UpdateOptions
172 ): Promise<UpdateResult<Infer<T>>> {
173 return await core.updateOne(this.collection, this.schema, query, data, options);
174 }
175
176 /**
177 * Replace a single document matching the query
178 *
179 * @param query - MongoDB query filter
180 * @param data - Complete document data for replacement
181 * @param options - Replace options (including session for transactions)
182 * @returns Update result
183 */
184 async replaceOne(
185 query: Filter<Infer<T>>,
186 data: Input<T>,
187 options?: ReplaceOptions
188 ): Promise<UpdateResult<Infer<T>>> {
189 return await core.replaceOne(this.collection, this.schema, query, data, options);
190 }
191
192 /**
193 * Delete multiple documents matching the query
194 *
195 * @param query - MongoDB query filter
196 * @param options - Delete options (including session for transactions)
197 * @returns Delete result
198 */
199 async delete(
200 query: Filter<Infer<T>>,
201 options?: DeleteOptions
202 ): Promise<DeleteResult> {
203 return await core.deleteMany(this.collection, query, options);
204 }
205
206 /**
207 * Delete a single document matching the query
208 *
209 * @param query - MongoDB query filter
210 * @param options - Delete options (including session for transactions)
211 * @returns Delete result
212 */
213 async deleteOne(
214 query: Filter<Infer<T>>,
215 options?: DeleteOptions
216 ): Promise<DeleteResult> {
217 return await core.deleteOne(this.collection, query, options);
218 }
219
220 /**
221 * Count documents matching the query
222 *
223 * @param query - MongoDB query filter
224 * @param options - Count options (including session for transactions)
225 * @returns Number of matching documents
226 */
227 async count(
228 query: Filter<Infer<T>>,
229 options?: CountDocumentsOptions
230 ): Promise<number> {
231 return await core.count(this.collection, query, options);
232 }
233
234 /**
235 * Execute an aggregation pipeline
236 *
237 * @param pipeline - MongoDB aggregation pipeline
238 * @param options - Aggregate options (including session for transactions)
239 * @returns Array of aggregation results
240 */
241 async aggregate(
242 pipeline: Document[],
243 options?: AggregateOptions
244 ): Promise<Document[]> {
245 return await core.aggregate(this.collection, pipeline, options);
246 }
247
248 // ============================================================================
249 // Pagination (delegated to pagination.ts)
250 // ============================================================================
251
252 /**
253 * Find documents with pagination support
254 *
255 * @param query - MongoDB query filter
256 * @param options - Pagination options (skip, limit, sort)
257 * @returns Array of matching documents
258 */
259 async findPaginated(
260 query: Filter<Infer<T>>,
261 options: { skip?: number; limit?: number; sort?: Document } = {},
262 ): Promise<(WithId<Infer<T>>)[]> {
263 return await pagination.findPaginated(this.collection, query, options);
264 }
265
266 // ============================================================================
267 // Index Management (delegated to indexes.ts)
268 // ============================================================================
269
270 /**
271 * Create a single index on the collection
272 *
273 * @param keys - Index specification (e.g., { email: 1 } or { name: "text" })
274 * @param options - Index creation options (unique, sparse, expireAfterSeconds, etc.)
275 * @returns The name of the created index
276 */
277 async createIndex(
278 keys: IndexSpecification,
279 options?: CreateIndexesOptions,
280 ): Promise<string> {
281 return await indexes.createIndex(this.collection, keys, options);
282 }
283
284 /**
285 * Create multiple indexes on the collection
286 *
287 * @param indexes - Array of index descriptions
288 * @param options - Index creation options
289 * @returns Array of index names created
290 */
291 async createIndexes(
292 indexList: IndexDescription[],
293 options?: CreateIndexesOptions,
294 ): Promise<string[]> {
295 return await indexes.createIndexes(this.collection, indexList, options);
296 }
297
298 /**
299 * Drop a single index from the collection
300 *
301 * @param index - Index name or specification
302 * @param options - Drop index options
303 */
304 async dropIndex(
305 index: string | IndexSpecification,
306 options?: DropIndexesOptions,
307 ): Promise<void> {
308 return await indexes.dropIndex(this.collection, index, options);
309 }
310
311 /**
312 * Drop all indexes from the collection (except _id index)
313 *
314 * @param options - Drop index options
315 */
316 async dropIndexes(options?: DropIndexesOptions): Promise<void> {
317 return await indexes.dropIndexes(this.collection, options);
318 }
319
320 /**
321 * List all indexes on the collection
322 *
323 * @param options - List indexes options
324 * @returns Array of index information
325 */
326 async listIndexes(
327 options?: ListIndexesOptions,
328 ): Promise<IndexDescription[]> {
329 return await indexes.listIndexes(this.collection, options);
330 }
331
332 /**
333 * Get index information by name
334 *
335 * @param indexName - Name of the index
336 * @returns Index description or null if not found
337 */
338 async getIndex(indexName: string): Promise<IndexDescription | null> {
339 return await indexes.getIndex(this.collection, indexName);
340 }
341
342 /**
343 * Check if an index exists
344 *
345 * @param indexName - Name of the index
346 * @returns True if index exists, false otherwise
347 */
348 async indexExists(indexName: string): Promise<boolean> {
349 return await indexes.indexExists(this.collection, indexName);
350 }
351
352 /**
353 * Synchronize indexes - create indexes if they don't exist, update if they differ
354 *
355 * This is useful for ensuring indexes match your schema definition
356 *
357 * @param indexes - Array of index descriptions to synchronize
358 * @param options - Options for index creation
359 * @returns Array of index names that were created
360 */
361 async syncIndexes(
362 indexList: IndexDescription[],
363 options?: CreateIndexesOptions,
364 ): Promise<string[]> {
365 return await indexes.syncIndexes(this.collection, indexList, options);
366 }
367}