Thin MongoDB ODM built for Standard Schema
mongodb
zod
deno
1import type { z } from "@zod/zod";
2import type {
3 AggregateOptions,
4 BulkWriteOptions,
5 Collection,
6 CountDocumentsOptions,
7 DeleteOptions,
8 DeleteResult,
9 Document,
10 Filter,
11 FindOneAndReplaceOptions,
12 FindOneAndUpdateOptions,
13 FindOptions,
14 InsertManyResult,
15 InsertOneOptions,
16 InsertOneResult,
17 ModifyResult,
18 OptionalUnlessRequiredId,
19 ReplaceOptions,
20 UpdateFilter,
21 UpdateOptions,
22 UpdateResult,
23 WithId,
24} from "mongodb";
25import { ObjectId } from "mongodb";
26import type { Infer, Input, Schema } from "../types.ts";
27import {
28 applyDefaultsForUpsert,
29 parse,
30 parsePartial,
31 parseReplace,
32} from "./validation.ts";
33
34/**
35 * Core CRUD operations for the Model class
36 *
37 * This module contains all basic create, read, update, and delete operations
38 * with automatic Zod validation and transaction support.
39 */
40
41/**
42 * Insert a single document into the collection
43 *
44 * @param collection - MongoDB collection
45 * @param schema - Zod schema for validation
46 * @param data - Document data to insert
47 * @param options - Insert options (including session for transactions)
48 * @returns Insert result with insertedId
49 */
50export async function insertOne<T extends Schema>(
51 collection: Collection<Infer<T>>,
52 schema: T,
53 data: Input<T>,
54 options?: InsertOneOptions,
55): Promise<InsertOneResult<Infer<T>>> {
56 const validatedData = parse(schema, data);
57 return await collection.insertOne(
58 validatedData as OptionalUnlessRequiredId<Infer<T>>,
59 options,
60 );
61}
62
63/**
64 * Insert multiple documents into the collection
65 *
66 * @param collection - MongoDB collection
67 * @param schema - Zod schema for validation
68 * @param data - Array of document data to insert
69 * @param options - Insert options (including session for transactions)
70 * @returns Insert result with insertedIds
71 */
72export async function insertMany<T extends Schema>(
73 collection: Collection<Infer<T>>,
74 schema: T,
75 data: Input<T>[],
76 options?: BulkWriteOptions,
77): Promise<InsertManyResult<Infer<T>>> {
78 const validatedData = data.map((item) => parse(schema, item));
79 return await collection.insertMany(
80 validatedData as OptionalUnlessRequiredId<Infer<T>>[],
81 options,
82 );
83}
84
85/**
86 * Find multiple documents matching the query
87 *
88 * @param collection - MongoDB collection
89 * @param query - MongoDB query filter
90 * @param options - Find options (including session for transactions)
91 * @returns Array of matching documents
92 */
93export async function find<T extends Schema>(
94 collection: Collection<Infer<T>>,
95 query: Filter<Infer<T>>,
96 options?: FindOptions,
97): Promise<(WithId<Infer<T>>)[]> {
98 return await collection.find(query, options).toArray();
99}
100
101/**
102 * Find a single document matching the query
103 *
104 * @param collection - MongoDB collection
105 * @param query - MongoDB query filter
106 * @param options - Find options (including session for transactions)
107 * @returns Matching document or null if not found
108 */
109export async function findOne<T extends Schema>(
110 collection: Collection<Infer<T>>,
111 query: Filter<Infer<T>>,
112 options?: FindOptions,
113): Promise<WithId<Infer<T>> | null> {
114 return await collection.findOne(query, options);
115}
116
117/**
118 * Find a document by its MongoDB ObjectId
119 *
120 * @param collection - MongoDB collection
121 * @param id - Document ID (string or ObjectId)
122 * @param options - Find options (including session for transactions)
123 * @returns Matching document or null if not found
124 */
125export async function findById<T extends Schema>(
126 collection: Collection<Infer<T>>,
127 id: string | ObjectId,
128 options?: FindOptions,
129): Promise<WithId<Infer<T>> | null> {
130 const objectId = typeof id === "string" ? new ObjectId(id) : id;
131 return await findOne(
132 collection,
133 { _id: objectId } as Filter<Infer<T>>,
134 options,
135 );
136}
137
138/**
139 * Update multiple documents matching the query
140 *
141 * Case handling:
142 * - If upsert: false (or undefined) → Normal update, no defaults applied
143 * - If upsert: true → Defaults added to $setOnInsert for new document creation
144 *
145 * @param collection - MongoDB collection
146 * @param schema - Zod schema for validation
147 * @param query - MongoDB query filter
148 * @param data - Partial data to update
149 * @param options - Update options (including session for transactions and upsert flag)
150 * @returns Update result
151 */
152export async function update<T extends Schema>(
153 collection: Collection<Infer<T>>,
154 schema: T,
155 query: Filter<Infer<T>>,
156 data: Partial<z.infer<T>>,
157 options?: UpdateOptions,
158): Promise<UpdateResult<Infer<T>>> {
159 const validatedData = parsePartial(schema, data);
160 let updateDoc: UpdateFilter<Infer<T>> = {
161 $set: validatedData as Partial<Infer<T>>,
162 };
163
164 // If this is an upsert, apply defaults using $setOnInsert
165 if (options?.upsert) {
166 updateDoc = applyDefaultsForUpsert(schema, query, updateDoc);
167 }
168
169 return await collection.updateMany(query, updateDoc, options);
170}
171
172/**
173 * Update a single document matching the query
174 *
175 * Case handling:
176 * - If upsert: false (or undefined) → Normal update, no defaults applied
177 * - If upsert: true → Defaults added to $setOnInsert for new document creation
178 *
179 * @param collection - MongoDB collection
180 * @param schema - Zod schema for validation
181 * @param query - MongoDB query filter
182 * @param data - Partial data to update
183 * @param options - Update options (including session for transactions and upsert flag)
184 * @returns Update result
185 */
186export async function updateOne<T extends Schema>(
187 collection: Collection<Infer<T>>,
188 schema: T,
189 query: Filter<Infer<T>>,
190 data: Partial<z.infer<T>>,
191 options?: UpdateOptions,
192): Promise<UpdateResult<Infer<T>>> {
193 const validatedData = parsePartial(schema, data);
194 let updateDoc: UpdateFilter<Infer<T>> = {
195 $set: validatedData as Partial<Infer<T>>,
196 };
197
198 // If this is an upsert, apply defaults using $setOnInsert
199 if (options?.upsert) {
200 updateDoc = applyDefaultsForUpsert(schema, query, updateDoc);
201 }
202
203 return await collection.updateOne(query, updateDoc, options);
204}
205
206/**
207 * Replace a single document matching the query
208 *
209 * Case handling:
210 * - If upsert: false (or undefined) → Normal replace on existing doc, no additional defaults
211 * - If upsert: true → Defaults applied via parse() since we're passing a full document
212 *
213 * Note: For replace operations, defaults are automatically applied by the schema's
214 * parse() function which treats missing fields as candidates for defaults. This works
215 * for both regular replaces and upsert-creates since we're providing a full document.
216 *
217 * @param collection - MongoDB collection
218 * @param schema - Zod schema for validation
219 * @param query - MongoDB query filter
220 * @param data - Complete document data for replacement
221 * @param options - Replace options (including session for transactions and upsert flag)
222 * @returns Update result
223 */
224export async function replaceOne<T extends Schema>(
225 collection: Collection<Infer<T>>,
226 schema: T,
227 query: Filter<Infer<T>>,
228 data: Input<T>,
229 options?: ReplaceOptions,
230): Promise<UpdateResult<Infer<T>>> {
231 // parseReplace will apply all schema defaults to missing fields
232 // This works correctly for both regular replaces and upsert-created documents
233 const validatedData = parseReplace(schema, data);
234
235 // Remove _id from validatedData for replaceOne (it will use the query's _id)
236 const { _id, ...withoutId } = validatedData as Infer<T> & { _id?: unknown };
237 return await collection.replaceOne(
238 query,
239 withoutId as Infer<T>,
240 options,
241 );
242}
243
244/**
245 * Find a single document and update it
246 *
247 * Case handling:
248 * - If upsert: false (or undefined) → Normal update
249 * - If upsert: true → Defaults added to $setOnInsert for new document creation
250 */
251export async function findOneAndUpdate<T extends Schema>(
252 collection: Collection<Infer<T>>,
253 schema: T,
254 query: Filter<Infer<T>>,
255 data: Partial<z.infer<T>>,
256 options?: FindOneAndUpdateOptions,
257): Promise<ModifyResult<Infer<T>>> {
258 const validatedData = parsePartial(schema, data);
259 let updateDoc: UpdateFilter<Infer<T>> = {
260 $set: validatedData as Partial<Infer<T>>,
261 };
262
263 if (options?.upsert) {
264 updateDoc = applyDefaultsForUpsert(schema, query, updateDoc);
265 }
266
267 const resolvedOptions: FindOneAndUpdateOptions & {
268 includeResultMetadata: true;
269 } = {
270 ...(options ?? {}),
271 includeResultMetadata: true as const,
272 };
273
274 return await collection.findOneAndUpdate(query, updateDoc, resolvedOptions);
275}
276
277/**
278 * Find a single document and replace it
279 *
280 * Defaults are applied via parseReplace(), which fills in missing fields
281 * for both normal replacements and upsert-created documents.
282 */
283export async function findOneAndReplace<T extends Schema>(
284 collection: Collection<Infer<T>>,
285 schema: T,
286 query: Filter<Infer<T>>,
287 data: Input<T>,
288 options?: FindOneAndReplaceOptions,
289): Promise<ModifyResult<Infer<T>>> {
290 const validatedData = parseReplace(schema, data);
291 const { _id, ...withoutId } = validatedData as Infer<T> & { _id?: unknown };
292
293 const resolvedOptions: FindOneAndReplaceOptions & {
294 includeResultMetadata: true;
295 } = {
296 ...(options ?? {}),
297 includeResultMetadata: true as const,
298 };
299
300 return await collection.findOneAndReplace(
301 query,
302 withoutId as Infer<T>,
303 resolvedOptions,
304 );
305}
306
307/**
308 * Delete multiple documents matching the query
309 *
310 * @param collection - MongoDB collection
311 * @param query - MongoDB query filter
312 * @param options - Delete options (including session for transactions)
313 * @returns Delete result
314 */
315export async function deleteMany<T extends Schema>(
316 collection: Collection<Infer<T>>,
317 query: Filter<Infer<T>>,
318 options?: DeleteOptions,
319): Promise<DeleteResult> {
320 return await collection.deleteMany(query, options);
321}
322
323/**
324 * Delete a single document matching the query
325 *
326 * @param collection - MongoDB collection
327 * @param query - MongoDB query filter
328 * @param options - Delete options (including session for transactions)
329 * @returns Delete result
330 */
331export async function deleteOne<T extends Schema>(
332 collection: Collection<Infer<T>>,
333 query: Filter<Infer<T>>,
334 options?: DeleteOptions,
335): Promise<DeleteResult> {
336 return await collection.deleteOne(query, options);
337}
338
339/**
340 * Count documents matching the query
341 *
342 * @param collection - MongoDB collection
343 * @param query - MongoDB query filter
344 * @param options - Count options (including session for transactions)
345 * @returns Number of matching documents
346 */
347export async function count<T extends Schema>(
348 collection: Collection<Infer<T>>,
349 query: Filter<Infer<T>>,
350 options?: CountDocumentsOptions,
351): Promise<number> {
352 return await collection.countDocuments(query, options);
353}
354
355/**
356 * Execute an aggregation pipeline
357 *
358 * @param collection - MongoDB collection
359 * @param pipeline - MongoDB aggregation pipeline
360 * @param options - Aggregate options (including session for transactions)
361 * @returns Array of aggregation results
362 */
363export async function aggregate<T extends Schema>(
364 collection: Collection<Infer<T>>,
365 pipeline: Document[],
366 options?: AggregateOptions,
367): Promise<Document[]> {
368 return await collection.aggregate(pipeline, options).toArray();
369}