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}