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