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