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 OptionalUnlessRequiredId,
22 UpdateResult,
23 WithId,
24 BulkWriteOptions,
25} from "mongodb";
26import { ObjectId } from "mongodb";
27import { getDb } from "./client.ts";
28import { ValidationError, AsyncValidationError } from "./errors.ts";
29
30// Type alias for cleaner code - Zod schema
31type Schema = z.ZodObject;
32type Infer<T extends Schema> = z.infer<T> & Document;
33type Input<T extends Schema> = z.input<T>;
34
35// Helper function to validate data using Zod
36function parse<T extends Schema>(schema: T, data: Input<T>): Infer<T> {
37 const result = schema.safeParse(data);
38
39 // Check for async validation
40 if (result instanceof Promise) {
41 throw new AsyncValidationError();
42 }
43
44 if (!result.success) {
45 throw new ValidationError(result.error.issues, "insert");
46 }
47 return result.data as Infer<T>;
48}
49
50// Helper function to validate partial update data using Zod's partial()
51function parsePartial<T extends Schema>(
52 schema: T,
53 data: Partial<z.infer<T>>,
54): Partial<z.infer<T>> {
55 const result = schema.partial().safeParse(data);
56
57 // Check for async validation
58 if (result instanceof Promise) {
59 throw new AsyncValidationError();
60 }
61
62 if (!result.success) {
63 throw new ValidationError(result.error.issues, "update");
64 }
65 return result.data as Partial<z.infer<T>>;
66}
67
68// Helper function to validate replace data using Zod
69function parseReplace<T extends Schema>(schema: T, data: Input<T>): Infer<T> {
70 const result = schema.safeParse(data);
71
72 // Check for async validation
73 if (result instanceof Promise) {
74 throw new AsyncValidationError();
75 }
76
77 if (!result.success) {
78 throw new ValidationError(result.error.issues, "replace");
79 }
80 return result.data as Infer<T>;
81}
82
83export class Model<T extends Schema> {
84 private collection: Collection<Infer<T>>;
85 private schema: T;
86
87 constructor(collectionName: string, schema: T) {
88 this.collection = getDb().collection<Infer<T>>(collectionName);
89 this.schema = schema;
90 }
91
92 async insertOne(
93 data: Input<T>,
94 options?: InsertOneOptions
95 ): Promise<InsertOneResult<Infer<T>>> {
96 const validatedData = parse(this.schema, data);
97 return await this.collection.insertOne(
98 validatedData as OptionalUnlessRequiredId<Infer<T>>,
99 options
100 );
101 }
102
103 async insertMany(
104 data: Input<T>[],
105 options?: BulkWriteOptions
106 ): Promise<InsertManyResult<Infer<T>>> {
107 const validatedData = data.map((item) => parse(this.schema, item));
108 return await this.collection.insertMany(
109 validatedData as OptionalUnlessRequiredId<Infer<T>>[],
110 options
111 );
112 }
113
114 async find(
115 query: Filter<Infer<T>>,
116 options?: FindOptions
117 ): Promise<(WithId<Infer<T>>)[]> {
118 return await this.collection.find(query, options).toArray();
119 }
120
121 async findOne(
122 query: Filter<Infer<T>>,
123 options?: FindOptions
124 ): Promise<WithId<Infer<T>> | null> {
125 return await this.collection.findOne(query, options);
126 }
127
128 async findById(
129 id: string | ObjectId,
130 options?: FindOptions
131 ): Promise<WithId<Infer<T>> | null> {
132 const objectId = typeof id === "string" ? new ObjectId(id) : id;
133 return await this.findOne({ _id: objectId } as Filter<Infer<T>>, options);
134 }
135
136 async update(
137 query: Filter<Infer<T>>,
138 data: Partial<z.infer<T>>,
139 options?: UpdateOptions
140 ): Promise<UpdateResult<Infer<T>>> {
141 const validatedData = parsePartial(this.schema, data);
142 return await this.collection.updateMany(
143 query,
144 { $set: validatedData as Partial<Infer<T>> },
145 options
146 );
147 }
148
149 async updateOne(
150 query: Filter<Infer<T>>,
151 data: Partial<z.infer<T>>,
152 options?: UpdateOptions
153 ): Promise<UpdateResult<Infer<T>>> {
154 const validatedData = parsePartial(this.schema, data);
155 return await this.collection.updateOne(
156 query,
157 { $set: validatedData as Partial<Infer<T>> },
158 options
159 );
160 }
161
162 async replaceOne(
163 query: Filter<Infer<T>>,
164 data: Input<T>,
165 options?: ReplaceOptions
166 ): Promise<UpdateResult<Infer<T>>> {
167 const validatedData = parseReplace(this.schema, data);
168 // Remove _id from validatedData for replaceOne (it will use the query's _id)
169 const { _id, ...withoutId } = validatedData as Infer<T> & { _id?: unknown };
170 return await this.collection.replaceOne(
171 query,
172 withoutId as Infer<T>,
173 options
174 );
175 }
176
177 async delete(
178 query: Filter<Infer<T>>,
179 options?: DeleteOptions
180 ): Promise<DeleteResult> {
181 return await this.collection.deleteMany(query, options);
182 }
183
184 async deleteOne(
185 query: Filter<Infer<T>>,
186 options?: DeleteOptions
187 ): Promise<DeleteResult> {
188 return await this.collection.deleteOne(query, options);
189 }
190
191 async count(
192 query: Filter<Infer<T>>,
193 options?: CountDocumentsOptions
194 ): Promise<number> {
195 return await this.collection.countDocuments(query, options);
196 }
197
198 async aggregate(
199 pipeline: Document[],
200 options?: AggregateOptions
201 ): Promise<Document[]> {
202 return await this.collection.aggregate(pipeline, options).toArray();
203 }
204
205 // Pagination support for find
206 async findPaginated(
207 query: Filter<Infer<T>>,
208 options: { skip?: number; limit?: number; sort?: Document } = {},
209 ): Promise<(WithId<Infer<T>>)[]> {
210 return await this.collection
211 .find(query)
212 .skip(options.skip ?? 0)
213 .limit(options.limit ?? 10)
214 .sort(options.sort ?? {})
215 .toArray();
216 }
217
218 // Index Management Methods
219
220 /**
221 * Create a single index on the collection
222 * @param keys - Index specification (e.g., { email: 1 } or { name: "text" })
223 * @param options - Index creation options (unique, sparse, expireAfterSeconds, etc.)
224 * @returns The name of the created index
225 */
226 async createIndex(
227 keys: IndexSpecification,
228 options?: CreateIndexesOptions,
229 ): Promise<string> {
230 return await this.collection.createIndex(keys, options);
231 }
232
233 /**
234 * Create multiple indexes on the collection
235 * @param indexes - Array of index descriptions
236 * @param options - Index creation options
237 * @returns Array of index names created
238 */
239 async createIndexes(
240 indexes: IndexDescription[],
241 options?: CreateIndexesOptions,
242 ): Promise<string[]> {
243 return await this.collection.createIndexes(indexes, options);
244 }
245
246 /**
247 * Drop a single index from the collection
248 * @param index - Index name or specification
249 * @param options - Drop index options
250 */
251 async dropIndex(
252 index: string | IndexSpecification,
253 options?: DropIndexesOptions,
254 ): Promise<void> {
255 // MongoDB driver accepts string or IndexSpecification
256 await this.collection.dropIndex(index as string, options);
257 }
258
259 /**
260 * Drop all indexes from the collection (except _id index)
261 * @param options - Drop index options
262 */
263 async dropIndexes(options?: DropIndexesOptions): Promise<void> {
264 await this.collection.dropIndexes(options);
265 }
266
267 /**
268 * List all indexes on the collection
269 * @param options - List indexes options
270 * @returns Array of index information
271 */
272 async listIndexes(
273 options?: ListIndexesOptions,
274 ): Promise<IndexDescription[]> {
275 const indexes = await this.collection.listIndexes(options).toArray();
276 return indexes as IndexDescription[];
277 }
278
279 /**
280 * Get index information by name
281 * @param indexName - Name of the index
282 * @returns Index description or null if not found
283 */
284 async getIndex(indexName: string): Promise<IndexDescription | null> {
285 const indexes = await this.listIndexes();
286 return indexes.find((idx) => idx.name === indexName) || null;
287 }
288
289 /**
290 * Check if an index exists
291 * @param indexName - Name of the index
292 * @returns True if index exists, false otherwise
293 */
294 async indexExists(indexName: string): Promise<boolean> {
295 const index = await this.getIndex(indexName);
296 return index !== null;
297 }
298
299 /**
300 * Synchronize indexes - create indexes if they don't exist, update if they differ
301 * This is useful for ensuring indexes match your schema definition
302 * @param indexes - Array of index descriptions to synchronize
303 * @param options - Options for index creation
304 */
305 async syncIndexes(
306 indexes: IndexDescription[],
307 options?: CreateIndexesOptions,
308 ): Promise<string[]> {
309 const existingIndexes = await this.listIndexes();
310
311 const indexesToCreate: IndexDescription[] = [];
312
313 for (const index of indexes) {
314 const indexName = index.name || this._generateIndexName(index.key);
315 const existingIndex = existingIndexes.find(
316 (idx) => idx.name === indexName,
317 );
318
319 if (!existingIndex) {
320 indexesToCreate.push(index);
321 } else if (
322 JSON.stringify(existingIndex.key) !== JSON.stringify(index.key)
323 ) {
324 // Index exists but keys differ - drop and recreate
325 await this.dropIndex(indexName);
326 indexesToCreate.push(index);
327 }
328 // If index exists and matches, skip it
329 }
330
331 const created: string[] = [];
332 if (indexesToCreate.length > 0) {
333 const names = await this.createIndexes(indexesToCreate, options);
334 created.push(...names);
335 }
336
337 return created;
338 }
339
340 /**
341 * Helper method to generate index name from key specification
342 */
343 private _generateIndexName(keys: IndexSpecification): string {
344 if (typeof keys === "string") {
345 return keys;
346 }
347 const entries = Object.entries(keys as Record<string, number | string>);
348 return entries.map(([field, direction]) => `${field}_${direction}`).join("_");
349 }
350}