Thin MongoDB ODM built for Standard Schema
mongodb zod deno

bench

knotbin.com 93878ec0 a9f7213d

verified
+931 -511
+2 -2
.vscode/settings.json
··· 1 { 2 - "git.enabled": false 3 - }
··· 1 { 2 + "git.enabled": false 3 + }
+32 -31
README.md
··· 8 9 ## ✨ Features 10 11 - - **Schema-first:** Define and validate collections using [Zod](https://zod.dev). 12 - **Type-safe operations:** Auto-complete and strict typings for `insert`, 13 `find`, `update`, and `delete`. 14 - **Minimal & modular:** No decorators or magic. Just clean, composable APIs. ··· 51 52 ```ts 53 // src/index.ts 54 - import { 55 - connect, 56 - disconnect, 57 - InferModel, 58 - Input, 59 - Model, 60 - } from "@nozzle/nozzle"; 61 import { userSchema } from "./schemas/user"; 62 import { ObjectId } from "mongodb"; // v6+ driver recommended 63 ··· 67 async function main() { 68 // Basic connection 69 await connect("mongodb://localhost:27017", "your_database_name"); 70 - 71 // Or with connection pooling options 72 await connect("mongodb://localhost:27017", "your_database_name", { 73 - maxPoolSize: 10, // Maximum connections in pool 74 - minPoolSize: 2, // Minimum connections in pool 75 - maxIdleTimeMS: 30000, // Close idle connections after 30s 76 connectTimeoutMS: 10000, // Connection timeout 77 - socketTimeoutMS: 45000, // Socket timeout 78 }); 79 - 80 // Production-ready connection with retry logic and resilience 81 await connect("mongodb://localhost:27017", "your_database_name", { 82 // Connection pooling 83 maxPoolSize: 10, 84 minPoolSize: 2, 85 - 86 // Automatic retry logic (enabled by default) 87 - retryReads: true, // Retry failed read operations 88 - retryWrites: true, // Retry failed write operations 89 - 90 // Timeouts 91 - connectTimeoutMS: 10000, // Initial connection timeout 92 - socketTimeoutMS: 45000, // Socket operation timeout 93 serverSelectionTimeoutMS: 10000, // Server selection timeout 94 - 95 // Connection resilience 96 - maxIdleTimeMS: 30000, // Close idle connections 97 heartbeatFrequencyMS: 10000, // Server health check interval 98 }); 99 - 100 const UserModel = new Model("users", userSchema); 101 102 // Your operations go here ··· 210 // All operations in this callback are part of the same transaction 211 const user = await UserModel.insertOne( 212 { name: "Alice", email: "alice@example.com" }, 213 - { session } // Pass session to each operation 214 ); 215 - 216 const order = await OrderModel.insertOne( 217 { userId: user.insertedId, total: 100 }, 218 - { session } 219 ); 220 - 221 // If any operation fails, the entire transaction is automatically aborted 222 // If callback succeeds, transaction is automatically committed 223 return { user, order }; 224 }); 225 226 // Manual session management (for advanced use cases) 227 - import { startSession, endSession } from "@nozzle/nozzle"; 228 229 const session = startSession(); 230 try { 231 await session.withTransaction(async () => { 232 - await UserModel.insertOne({ name: "Bob", email: "bob@example.com" }, { session }); 233 await UserModel.updateOne({ name: "Alice" }, { balance: 50 }, { session }); 234 }); 235 } finally { ··· 237 } 238 239 // Error Handling 240 - import { ValidationError, ConnectionError } from "@nozzle/nozzle"; 241 242 try { 243 await UserModel.insertOne({ name: "", email: "invalid" }); ··· 261 ## 🗺️ Roadmap 262 263 ### 🔴 Critical (Must Have) 264 - [x] Transactions support 265 - [x] Connection retry logic 266 - [x] Improved error handling ··· 268 - [x] Connection pooling configuration 269 270 ### 🟡 Important (Should Have) 271 - [x] Index management 272 - [ ] Middleware/hooks system 273 - [ ] Relationship/population support ··· 275 - [ ] Comprehensive edge case testing 276 277 ### 🟢 Nice to Have 278 - [x] Pagination support 279 - [ ] Plugin system 280 - [ ] Query builder API 281 - [ ] Virtual fields 282 - [ ] Document/static methods 283 284 - For detailed production readiness assessment, see [PRODUCTION_READINESS_ASSESSMENT.md](./PRODUCTION_READINESS_ASSESSMENT.md). 285 286 --- 287
··· 8 9 ## ✨ Features 10 11 + - **Schema-first:** Define and validate collections using 12 + [Zod](https://zod.dev). 13 - **Type-safe operations:** Auto-complete and strict typings for `insert`, 14 `find`, `update`, and `delete`. 15 - **Minimal & modular:** No decorators or magic. Just clean, composable APIs. ··· 52 53 ```ts 54 // src/index.ts 55 + import { connect, disconnect, InferModel, Input, Model } from "@nozzle/nozzle"; 56 import { userSchema } from "./schemas/user"; 57 import { ObjectId } from "mongodb"; // v6+ driver recommended 58 ··· 62 async function main() { 63 // Basic connection 64 await connect("mongodb://localhost:27017", "your_database_name"); 65 + 66 // Or with connection pooling options 67 await connect("mongodb://localhost:27017", "your_database_name", { 68 + maxPoolSize: 10, // Maximum connections in pool 69 + minPoolSize: 2, // Minimum connections in pool 70 + maxIdleTimeMS: 30000, // Close idle connections after 30s 71 connectTimeoutMS: 10000, // Connection timeout 72 + socketTimeoutMS: 45000, // Socket timeout 73 }); 74 + 75 // Production-ready connection with retry logic and resilience 76 await connect("mongodb://localhost:27017", "your_database_name", { 77 // Connection pooling 78 maxPoolSize: 10, 79 minPoolSize: 2, 80 + 81 // Automatic retry logic (enabled by default) 82 + retryReads: true, // Retry failed read operations 83 + retryWrites: true, // Retry failed write operations 84 + 85 // Timeouts 86 + connectTimeoutMS: 10000, // Initial connection timeout 87 + socketTimeoutMS: 45000, // Socket operation timeout 88 serverSelectionTimeoutMS: 10000, // Server selection timeout 89 + 90 // Connection resilience 91 + maxIdleTimeMS: 30000, // Close idle connections 92 heartbeatFrequencyMS: 10000, // Server health check interval 93 }); 94 + 95 const UserModel = new Model("users", userSchema); 96 97 // Your operations go here ··· 205 // All operations in this callback are part of the same transaction 206 const user = await UserModel.insertOne( 207 { name: "Alice", email: "alice@example.com" }, 208 + { session }, // Pass session to each operation 209 ); 210 + 211 const order = await OrderModel.insertOne( 212 { userId: user.insertedId, total: 100 }, 213 + { session }, 214 ); 215 + 216 // If any operation fails, the entire transaction is automatically aborted 217 // If callback succeeds, transaction is automatically committed 218 return { user, order }; 219 }); 220 221 // Manual session management (for advanced use cases) 222 + import { endSession, startSession } from "@nozzle/nozzle"; 223 224 const session = startSession(); 225 try { 226 await session.withTransaction(async () => { 227 + await UserModel.insertOne({ name: "Bob", email: "bob@example.com" }, { 228 + session, 229 + }); 230 await UserModel.updateOne({ name: "Alice" }, { balance: 50 }, { session }); 231 }); 232 } finally { ··· 234 } 235 236 // Error Handling 237 + import { ConnectionError, ValidationError } from "@nozzle/nozzle"; 238 239 try { 240 await UserModel.insertOne({ name: "", email: "invalid" }); ··· 258 ## 🗺️ Roadmap 259 260 ### 🔴 Critical (Must Have) 261 + 262 - [x] Transactions support 263 - [x] Connection retry logic 264 - [x] Improved error handling ··· 266 - [x] Connection pooling configuration 267 268 ### 🟡 Important (Should Have) 269 + 270 - [x] Index management 271 - [ ] Middleware/hooks system 272 - [ ] Relationship/population support ··· 274 - [ ] Comprehensive edge case testing 275 276 ### 🟢 Nice to Have 277 + 278 - [x] Pagination support 279 - [ ] Plugin system 280 - [ ] Query builder API 281 - [ ] Virtual fields 282 - [ ] Document/static methods 283 284 + For detailed production readiness assessment, see 285 + [PRODUCTION_READINESS_ASSESSMENT.md](./PRODUCTION_READINESS_ASSESSMENT.md). 286 287 --- 288
+124
bench/ops_bench.ts
···
··· 1 + import { z } from "@zod/zod"; 2 + import { MongoMemoryServer } from "mongodb-memory-server-core"; 3 + import mongoose from "mongoose"; 4 + import { connect, disconnect, Model } from "../mod.ts"; 5 + 6 + /** 7 + * Benchmark basic CRUD operations for Nozzle vs Mongoose. 8 + * 9 + * Run with: 10 + * deno bench -A bench/nozzle_vs_mongoose.bench.ts 11 + */ 12 + 13 + const userSchema = z.object({ 14 + name: z.string(), 15 + email: z.string().email(), 16 + age: z.number().int().positive().optional(), 17 + createdAt: z.date().default(() => new Date()), 18 + }); 19 + 20 + const mongoServer = await MongoMemoryServer.create(); 21 + const uri = mongoServer.getUri(); 22 + 23 + // Use separate DBs to avoid any cross-driver interference 24 + const nozzleDbName = "bench_nozzle"; 25 + const mongooseDbName = "bench_mongoose"; 26 + 27 + await connect(uri, nozzleDbName); 28 + const NozzleUser = new Model("bench_users_nozzle", userSchema); 29 + 30 + const mongooseConn = await mongoose.connect(uri, { dbName: mongooseDbName }); 31 + const mongooseUserSchema = new mongoose.Schema( 32 + { 33 + name: String, 34 + email: String, 35 + age: Number, 36 + createdAt: { type: Date, default: Date.now }, 37 + }, 38 + { collection: "bench_users_mongoose" }, 39 + ); 40 + const MongooseUser = mongooseConn.models.BenchUser || 41 + mongooseConn.model("BenchUser", mongooseUserSchema); 42 + 43 + // Start from a clean state 44 + await NozzleUser.delete({}); 45 + await MongooseUser.deleteMany({}); 46 + 47 + // Seed base documents for read/update benches 48 + const nozzleSeed = await NozzleUser.insertOne({ 49 + name: "Seed Nozzle", 50 + email: "seed-nozzle@example.com", 51 + age: 30, 52 + }); 53 + const mongooseSeed = await MongooseUser.create({ 54 + name: "Seed Mongoose", 55 + email: "seed-mongoose@example.com", 56 + age: 30, 57 + }); 58 + 59 + const nozzleSeedId = nozzleSeed.insertedId; 60 + const mongooseSeedId = mongooseSeed._id; 61 + 62 + let counter = 0; 63 + const nextEmail = (prefix: string) => `${prefix}-${counter++}@bench.dev`; 64 + 65 + Deno.bench("mongoose insertOne", { group: "insertOne" }, async () => { 66 + await MongooseUser.insertOne({ 67 + name: "Mongoose User", 68 + email: nextEmail("mongoose"), 69 + age: 25, 70 + }); 71 + }); 72 + 73 + Deno.bench( 74 + "nozzle insertOne", 75 + { group: "insertOne", baseline: true }, 76 + async () => { 77 + await NozzleUser.insertOne({ 78 + name: "Nozzle User", 79 + email: nextEmail("nozzle"), 80 + age: 25, 81 + }); 82 + }, 83 + ); 84 + 85 + Deno.bench("mongoose findById", { group: "findById" }, async () => { 86 + await MongooseUser.findById(mongooseSeedId); 87 + }); 88 + 89 + Deno.bench( 90 + "nozzle findById", 91 + { group: "findById", baseline: true }, 92 + async () => { 93 + await NozzleUser.findById(nozzleSeedId); 94 + }, 95 + ); 96 + 97 + Deno.bench("mongoose updateOne", { group: "updateOne" }, async () => { 98 + await MongooseUser.updateOne( 99 + { _id: mongooseSeedId }, 100 + { $set: { age: 31 } }, 101 + ); 102 + }); 103 + 104 + Deno.bench( 105 + "nozzle updateOne", 106 + { group: "updateOne", baseline: true }, 107 + async () => { 108 + await NozzleUser.updateOne( 109 + { _id: nozzleSeedId }, 110 + { age: 31 }, 111 + ); 112 + }, 113 + ); 114 + 115 + // Attempt graceful shutdown when the process exits 116 + async function cleanup() { 117 + await disconnect(); 118 + await mongooseConn.disconnect(); 119 + await mongoServer.stop(); 120 + } 121 + 122 + globalThis.addEventListener("unload", () => { 123 + void cleanup(); 124 + });
+139
bench/results.json
···
··· 1 + { 2 + "version": 1, 3 + "runtime": "Deno/2.5.6 aarch64-apple-darwin", 4 + "cpu": "Apple M2 Pro", 5 + "benches": [ 6 + { 7 + "origin": "file:///Users/knotbin/Developer/nozzle/bench/ops_bench.ts", 8 + "group": "insertOne", 9 + "name": "mongoose insertOne", 10 + "baseline": false, 11 + "results": [ 12 + { 13 + "ok": { 14 + "n": 3733, 15 + "min": 85750.0, 16 + "max": 495459.0, 17 + "avg": 134257.0, 18 + "p75": 128917.0, 19 + "p99": 313291.0, 20 + "p995": 344708.0, 21 + "p999": 446833.0, 22 + "highPrecision": true, 23 + "usedExplicitTimers": false 24 + } 25 + } 26 + ] 27 + }, 28 + { 29 + "origin": "file:///Users/knotbin/Developer/nozzle/bench/ops_bench.ts", 30 + "group": "insertOne", 31 + "name": "nozzle insertOne", 32 + "baseline": true, 33 + "results": [ 34 + { 35 + "ok": { 36 + "n": 6354, 37 + "min": 52667.0, 38 + "max": 453875.0, 39 + "avg": 78809.0, 40 + "p75": 81417.0, 41 + "p99": 149417.0, 42 + "p995": 201459.0, 43 + "p999": 274750.0, 44 + "highPrecision": true, 45 + "usedExplicitTimers": false 46 + } 47 + } 48 + ] 49 + }, 50 + { 51 + "origin": "file:///Users/knotbin/Developer/nozzle/bench/ops_bench.ts", 52 + "group": "findById", 53 + "name": "mongoose findById", 54 + "baseline": false, 55 + "results": [ 56 + { 57 + "ok": { 58 + "n": 3707, 59 + "min": 113875.0, 60 + "max": 510125.0, 61 + "avg": 135223.0, 62 + "p75": 137167.0, 63 + "p99": 263958.0, 64 + "p995": 347458.0, 65 + "p999": 428500.0, 66 + "highPrecision": true, 67 + "usedExplicitTimers": false 68 + } 69 + } 70 + ] 71 + }, 72 + { 73 + "origin": "file:///Users/knotbin/Developer/nozzle/bench/ops_bench.ts", 74 + "group": "findById", 75 + "name": "nozzle findById", 76 + "baseline": true, 77 + "results": [ 78 + { 79 + "ok": { 80 + "n": 6045, 81 + "min": 70750.0, 82 + "max": 1008792.0, 83 + "avg": 82859.0, 84 + "p75": 83750.0, 85 + "p99": 132250.0, 86 + "p995": 183500.0, 87 + "p999": 311833.0, 88 + "highPrecision": true, 89 + "usedExplicitTimers": false 90 + } 91 + } 92 + ] 93 + }, 94 + { 95 + "origin": "file:///Users/knotbin/Developer/nozzle/bench/ops_bench.ts", 96 + "group": "updateOne", 97 + "name": "mongoose updateOne", 98 + "baseline": false, 99 + "results": [ 100 + { 101 + "ok": { 102 + "n": 4123, 103 + "min": 98500.0, 104 + "max": 717334.0, 105 + "avg": 121572.0, 106 + "p75": 123292.0, 107 + "p99": 179375.0, 108 + "p995": 281417.0, 109 + "p999": 342625.0, 110 + "highPrecision": true, 111 + "usedExplicitTimers": false 112 + } 113 + } 114 + ] 115 + }, 116 + { 117 + "origin": "file:///Users/knotbin/Developer/nozzle/bench/ops_bench.ts", 118 + "group": "updateOne", 119 + "name": "nozzle updateOne", 120 + "baseline": true, 121 + "results": [ 122 + { 123 + "ok": { 124 + "n": 6550, 125 + "min": 53833.0, 126 + "max": 401667.0, 127 + "avg": 76456.0, 128 + "p75": 76834.0, 129 + "p99": 118292.0, 130 + "p995": 181500.0, 131 + "p999": 299958.0, 132 + "highPrecision": true, 133 + "usedExplicitTimers": false 134 + } 135 + } 136 + ] 137 + } 138 + ] 139 + }
+16 -14
client/connection.ts
··· 1 - import { type Db, type MongoClientOptions, MongoClient } from "mongodb"; 2 import { ConnectionError } from "../errors.ts"; 3 4 /** 5 * Connection management module 6 - * 7 * Handles MongoDB connection lifecycle including connect, disconnect, 8 * and connection state management. 9 */ ··· 20 21 /** 22 * Connect to MongoDB with connection pooling, retry logic, and resilience options 23 - * 24 * The MongoDB driver handles connection pooling and automatic retries. 25 * Retry logic is enabled by default for both reads and writes in MongoDB 4.2+. 26 - * 27 * @param uri - MongoDB connection string 28 * @param dbName - Name of the database to connect to 29 * @param options - Connection options (pooling, retries, timeouts, etc.) 30 * @returns Connection object with client and db 31 - * 32 * @example 33 * Basic connection with pooling: 34 * ```ts ··· 40 * socketTimeoutMS: 45000, 41 * }); 42 * ``` 43 - * 44 * @example 45 * Production-ready connection with retry logic and resilience: 46 * ```ts ··· 48 * // Connection pooling 49 * maxPoolSize: 10, 50 * minPoolSize: 2, 51 - * 52 * // Automatic retry logic (enabled by default) 53 * retryReads: true, // Retry failed read operations 54 * retryWrites: true, // Retry failed write operations 55 - * 56 * // Timeouts 57 * connectTimeoutMS: 10000, // Initial connection timeout 58 * socketTimeoutMS: 45000, // Socket operation timeout 59 * serverSelectionTimeoutMS: 10000, // Server selection timeout 60 - * 61 * // Connection resilience 62 * maxIdleTimeMS: 30000, // Close idle connections 63 * heartbeatFrequencyMS: 10000, // Server health check interval 64 - * 65 * // Optional: Compression for reduced bandwidth 66 * compressors: ['snappy', 'zlib'], 67 * }); ··· 85 return connection; 86 } catch (error) { 87 throw new ConnectionError( 88 - `Failed to connect to MongoDB: ${error instanceof Error ? error.message : String(error)}`, 89 - uri 90 ); 91 } 92 } ··· 103 104 /** 105 * Get the current database connection 106 - * 107 * @returns MongoDB Db instance 108 * @throws {ConnectionError} If not connected 109 * @internal ··· 117 118 /** 119 * Get the current connection state 120 - * 121 * @returns Connection object or null if not connected 122 * @internal 123 */
··· 1 + import { type Db, MongoClient, type MongoClientOptions } from "mongodb"; 2 import { ConnectionError } from "../errors.ts"; 3 4 /** 5 * Connection management module 6 + * 7 * Handles MongoDB connection lifecycle including connect, disconnect, 8 * and connection state management. 9 */ ··· 20 21 /** 22 * Connect to MongoDB with connection pooling, retry logic, and resilience options 23 + * 24 * The MongoDB driver handles connection pooling and automatic retries. 25 * Retry logic is enabled by default for both reads and writes in MongoDB 4.2+. 26 + * 27 * @param uri - MongoDB connection string 28 * @param dbName - Name of the database to connect to 29 * @param options - Connection options (pooling, retries, timeouts, etc.) 30 * @returns Connection object with client and db 31 + * 32 * @example 33 * Basic connection with pooling: 34 * ```ts ··· 40 * socketTimeoutMS: 45000, 41 * }); 42 * ``` 43 + * 44 * @example 45 * Production-ready connection with retry logic and resilience: 46 * ```ts ··· 48 * // Connection pooling 49 * maxPoolSize: 10, 50 * minPoolSize: 2, 51 + * 52 * // Automatic retry logic (enabled by default) 53 * retryReads: true, // Retry failed read operations 54 * retryWrites: true, // Retry failed write operations 55 + * 56 * // Timeouts 57 * connectTimeoutMS: 10000, // Initial connection timeout 58 * socketTimeoutMS: 45000, // Socket operation timeout 59 * serverSelectionTimeoutMS: 10000, // Server selection timeout 60 + * 61 * // Connection resilience 62 * maxIdleTimeMS: 30000, // Close idle connections 63 * heartbeatFrequencyMS: 10000, // Server health check interval 64 + * 65 * // Optional: Compression for reduced bandwidth 66 * compressors: ['snappy', 'zlib'], 67 * }); ··· 85 return connection; 86 } catch (error) { 87 throw new ConnectionError( 88 + `Failed to connect to MongoDB: ${ 89 + error instanceof Error ? error.message : String(error) 90 + }`, 91 + uri, 92 ); 93 } 94 } ··· 105 106 /** 107 * Get the current database connection 108 + * 109 * @returns MongoDB Db instance 110 * @throws {ConnectionError} If not connected 111 * @internal ··· 119 120 /** 121 * Get the current connection state 122 + * 123 * @returns Connection object or null if not connected 124 * @internal 125 */
+5 -5
client/health.ts
··· 2 3 /** 4 * Health check module 5 - * 6 * Provides functionality for monitoring MongoDB connection health 7 * including ping operations and response time measurement. 8 */ 9 10 /** 11 * Health check details of the MongoDB connection 12 - * 13 * @property healthy - Overall health status of the connection 14 * @property connected - Whether a connection is established 15 * @property responseTimeMs - Response time in milliseconds (if connection is healthy) ··· 26 27 /** 28 * Check the health of the MongoDB connection 29 - * 30 * Performs a ping operation to verify the database is responsive 31 * and returns detailed health information including response time. 32 - * 33 * @returns Health check result with status and metrics 34 - * 35 * @example 36 * ```ts 37 * const health = await healthCheck();
··· 2 3 /** 4 * Health check module 5 + * 6 * Provides functionality for monitoring MongoDB connection health 7 * including ping operations and response time measurement. 8 */ 9 10 /** 11 * Health check details of the MongoDB connection 12 + * 13 * @property healthy - Overall health status of the connection 14 * @property connected - Whether a connection is established 15 * @property responseTimeMs - Response time in milliseconds (if connection is healthy) ··· 26 27 /** 28 * Check the health of the MongoDB connection 29 + * 30 * Performs a ping operation to verify the database is responsive 31 * and returns detailed health information including response time. 32 + * 33 * @returns Health check result with status and metrics 34 + * 35 * @example 36 * ```ts 37 * const health = await healthCheck();
+5 -12
client/index.ts
··· 1 /** 2 * Client module - MongoDB connection and session management 3 - * 4 * This module provides all client-level functionality including: 5 * - Connection management (connect, disconnect) 6 * - Health monitoring (healthCheck) ··· 10 // Re-export connection management 11 export { 12 connect, 13 disconnect, 14 getDb, 15 - type ConnectOptions, 16 - type Connection, 17 } from "./connection.ts"; 18 19 // Re-export health monitoring 20 - export { 21 - healthCheck, 22 - type HealthCheckResult, 23 - } from "./health.ts"; 24 25 // Re-export transaction management 26 - export { 27 - startSession, 28 - endSession, 29 - withTransaction, 30 - } from "./transactions.ts";
··· 1 /** 2 * Client module - MongoDB connection and session management 3 + * 4 * This module provides all client-level functionality including: 5 * - Connection management (connect, disconnect) 6 * - Health monitoring (healthCheck) ··· 10 // Re-export connection management 11 export { 12 connect, 13 + type Connection, 14 + type ConnectOptions, 15 disconnect, 16 getDb, 17 } from "./connection.ts"; 18 19 // Re-export health monitoring 20 + export { healthCheck, type HealthCheckResult } from "./health.ts"; 21 22 // Re-export transaction management 23 + export { endSession, startSession, withTransaction } from "./transactions.ts";
+12 -12
client/transactions.ts
··· 4 5 /** 6 * Transaction management module 7 - * 8 * Provides session and transaction management functionality including 9 * automatic transaction handling and manual session control. 10 */ 11 12 /** 13 * Start a new client session for transactions 14 - * 15 * Sessions must be ended when done using `endSession()` 16 - * 17 * @returns New MongoDB ClientSession 18 * @throws {ConnectionError} If not connected 19 - * 20 * @example 21 * ```ts 22 * const session = startSession(); ··· 37 38 /** 39 * End a client session 40 - * 41 * @param session - The session to end 42 */ 43 export async function endSession(session: ClientSession): Promise<void> { ··· 46 47 /** 48 * Execute a function within a transaction 49 - * 50 * Automatically handles session creation, transaction start/commit/abort, and cleanup. 51 * If the callback throws an error, the transaction is automatically aborted. 52 - * 53 * @param callback - Async function to execute within the transaction. Receives the session as parameter. 54 * @param options - Optional transaction options (read/write concern, etc.) 55 * @returns The result from the callback function 56 - * 57 * @example 58 * ```ts 59 * const result = await withTransaction(async (session) => { ··· 65 */ 66 export async function withTransaction<T>( 67 callback: (session: ClientSession) => Promise<T>, 68 - options?: TransactionOptions 69 ): Promise<T> { 70 const session = startSession(); 71 - 72 try { 73 let result: T; 74 - 75 await session.withTransaction(async () => { 76 result = await callback(session); 77 }, options); 78 - 79 return result!; 80 } finally { 81 await endSession(session);
··· 4 5 /** 6 * Transaction management module 7 + * 8 * Provides session and transaction management functionality including 9 * automatic transaction handling and manual session control. 10 */ 11 12 /** 13 * Start a new client session for transactions 14 + * 15 * Sessions must be ended when done using `endSession()` 16 + * 17 * @returns New MongoDB ClientSession 18 * @throws {ConnectionError} If not connected 19 + * 20 * @example 21 * ```ts 22 * const session = startSession(); ··· 37 38 /** 39 * End a client session 40 + * 41 * @param session - The session to end 42 */ 43 export async function endSession(session: ClientSession): Promise<void> { ··· 46 47 /** 48 * Execute a function within a transaction 49 + * 50 * Automatically handles session creation, transaction start/commit/abort, and cleanup. 51 * If the callback throws an error, the transaction is automatically aborted. 52 + * 53 * @param callback - Async function to execute within the transaction. Receives the session as parameter. 54 * @param options - Optional transaction options (read/write concern, etc.) 55 * @returns The result from the callback function 56 + * 57 * @example 58 * ```ts 59 * const result = await withTransaction(async (session) => { ··· 65 */ 66 export async function withTransaction<T>( 67 callback: (session: ClientSession) => Promise<T>, 68 + options?: TransactionOptions, 69 ): Promise<T> { 70 const session = startSession(); 71 + 72 try { 73 let result: T; 74 + 75 await session.withTransaction(async () => { 76 result = await callback(session); 77 }, options); 78 + 79 return result!; 80 } finally { 81 await endSession(session);
+2 -1
deno.json
··· 7 "@std/assert": "jsr:@std/assert@^1.0.16", 8 "@zod/zod": "jsr:@zod/zod@^4.1.13", 9 "mongodb": "npm:mongodb@^6.18.0", 10 - "mongodb-memory-server-core": "npm:mongodb-memory-server-core@^10.3.0" 11 } 12 }
··· 7 "@std/assert": "jsr:@std/assert@^1.0.16", 8 "@zod/zod": "jsr:@zod/zod@^4.1.13", 9 "mongodb": "npm:mongodb@^6.18.0", 10 + "mongodb-memory-server-core": "npm:mongodb-memory-server-core@^10.3.0", 11 + "mongoose": "npm:mongoose@^8.5.2" 12 } 13 }
+40 -3
deno.lock
··· 12 "jsr:@zod/zod@^4.1.13": "4.1.13", 13 "npm:@types/node@*": "22.15.15", 14 "npm:mongodb-memory-server-core@^10.3.0": "10.3.0", 15 - "npm:mongodb@^6.18.0": "6.18.0" 16 }, 17 "jsr": { 18 "@std/assert@1.0.13": { ··· 133 "debug" 134 ] 135 }, 136 "locate-path@5.0.0": { 137 "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", 138 "dependencies": [ ··· 164 "find-cache-dir", 165 "follow-redirects", 166 "https-proxy-agent", 167 - "mongodb", 168 "new-find-package-json", 169 "semver@7.7.3", 170 "tar-stream", ··· 180 "mongodb-connection-string-url" 181 ] 182 }, 183 "ms@2.1.3": { 184 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 185 }, ··· 226 "semver@7.7.3": { 227 "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", 228 "bin": true 229 }, 230 "sparse-bitfield@3.0.3": { 231 "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", ··· 290 "jsr:@std/assert@^1.0.16", 291 "jsr:@zod/zod@^4.1.13", 292 "npm:mongodb-memory-server-core@^10.3.0", 293 - "npm:mongodb@^6.18.0" 294 ] 295 } 296 }
··· 12 "jsr:@zod/zod@^4.1.13": "4.1.13", 13 "npm:@types/node@*": "22.15.15", 14 "npm:mongodb-memory-server-core@^10.3.0": "10.3.0", 15 + "npm:mongodb@^6.18.0": "6.20.0", 16 + "npm:mongoose@^8.5.2": "8.20.1" 17 }, 18 "jsr": { 19 "@std/assert@1.0.13": { ··· 134 "debug" 135 ] 136 }, 137 + "kareem@2.6.3": { 138 + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==" 139 + }, 140 "locate-path@5.0.0": { 141 "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", 142 "dependencies": [ ··· 168 "find-cache-dir", 169 "follow-redirects", 170 "https-proxy-agent", 171 + "mongodb@6.18.0", 172 "new-find-package-json", 173 "semver@7.7.3", 174 "tar-stream", ··· 184 "mongodb-connection-string-url" 185 ] 186 }, 187 + "mongodb@6.20.0": { 188 + "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", 189 + "dependencies": [ 190 + "@mongodb-js/saslprep", 191 + "bson", 192 + "mongodb-connection-string-url" 193 + ] 194 + }, 195 + "mongoose@8.20.1": { 196 + "integrity": "sha512-G+n3maddlqkQrP1nXxsI0q20144OSo+pe+HzRRGqaC4yK3FLYKqejqB9cbIi+SX7eoRsnG23LHGYNp8n7mWL2Q==", 197 + "dependencies": [ 198 + "bson", 199 + "kareem", 200 + "mongodb@6.20.0", 201 + "mpath", 202 + "mquery", 203 + "ms", 204 + "sift" 205 + ] 206 + }, 207 + "mpath@0.9.0": { 208 + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==" 209 + }, 210 + "mquery@5.0.0": { 211 + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", 212 + "dependencies": [ 213 + "debug" 214 + ] 215 + }, 216 "ms@2.1.3": { 217 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" 218 }, ··· 259 "semver@7.7.3": { 260 "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", 261 "bin": true 262 + }, 263 + "sift@17.1.3": { 264 + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==" 265 }, 266 "sparse-bitfield@3.0.3": { 267 "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", ··· 326 "jsr:@std/assert@^1.0.16", 327 "jsr:@zod/zod@^4.1.13", 328 "npm:mongodb-memory-server-core@^10.3.0", 329 + "npm:mongodb@^6.18.0", 330 + "npm:mongoose@^8.5.2" 331 ] 332 } 333 }
+21 -13
errors.ts
··· 24 export class ValidationError extends NozzleError { 25 public readonly issues: ValidationIssue[]; 26 public readonly operation: "insert" | "update" | "replace"; 27 - 28 - constructor(issues: ValidationIssue[], operation: "insert" | "update" | "replace") { 29 const message = ValidationError.formatIssues(issues); 30 super(`Validation failed on ${operation}: ${message}`); 31 this.issues = issues; ··· 33 } 34 35 private static formatIssues(issues: ValidationIssue[]): string { 36 - return issues.map(issue => { 37 - const path = issue.path.join('.'); 38 - return `${path || 'root'}: ${issue.message}`; 39 - }).join('; '); 40 } 41 42 /** ··· 45 public getFieldErrors(): Record<string, string[]> { 46 const fieldErrors: Record<string, string[]> = {}; 47 for (const issue of this.issues) { 48 - const field = issue.path.join('.') || 'root'; 49 if (!fieldErrors[field]) { 50 fieldErrors[field] = []; 51 } ··· 61 */ 62 export class ConnectionError extends NozzleError { 63 public readonly uri?: string; 64 - 65 constructor(message: string, uri?: string) { 66 super(message); 67 this.uri = uri; ··· 74 */ 75 export class ConfigurationError extends NozzleError { 76 public readonly option?: string; 77 - 78 constructor(message: string, option?: string) { 79 super(message); 80 this.option = option; ··· 88 export class DocumentNotFoundError extends NozzleError { 89 public readonly query: unknown; 90 public readonly collection: string; 91 - 92 constructor(collection: string, query: unknown) { 93 super(`Document not found in collection '${collection}'`); 94 this.collection = collection; ··· 104 public readonly operation: string; 105 public readonly collection?: string; 106 public override readonly cause?: Error; 107 - 108 - constructor(operation: string, message: string, collection?: string, cause?: Error) { 109 super(`${operation} operation failed: ${message}`); 110 this.operation = operation; 111 this.collection = collection; ··· 121 constructor() { 122 super( 123 "Async validation is not currently supported. " + 124 - "Please use synchronous validation schemas." 125 ); 126 } 127 }
··· 24 export class ValidationError extends NozzleError { 25 public readonly issues: ValidationIssue[]; 26 public readonly operation: "insert" | "update" | "replace"; 27 + 28 + constructor( 29 + issues: ValidationIssue[], 30 + operation: "insert" | "update" | "replace", 31 + ) { 32 const message = ValidationError.formatIssues(issues); 33 super(`Validation failed on ${operation}: ${message}`); 34 this.issues = issues; ··· 36 } 37 38 private static formatIssues(issues: ValidationIssue[]): string { 39 + return issues.map((issue) => { 40 + const path = issue.path.join("."); 41 + return `${path || "root"}: ${issue.message}`; 42 + }).join("; "); 43 } 44 45 /** ··· 48 public getFieldErrors(): Record<string, string[]> { 49 const fieldErrors: Record<string, string[]> = {}; 50 for (const issue of this.issues) { 51 + const field = issue.path.join(".") || "root"; 52 if (!fieldErrors[field]) { 53 fieldErrors[field] = []; 54 } ··· 64 */ 65 export class ConnectionError extends NozzleError { 66 public readonly uri?: string; 67 + 68 constructor(message: string, uri?: string) { 69 super(message); 70 this.uri = uri; ··· 77 */ 78 export class ConfigurationError extends NozzleError { 79 public readonly option?: string; 80 + 81 constructor(message: string, option?: string) { 82 super(message); 83 this.option = option; ··· 91 export class DocumentNotFoundError extends NozzleError { 92 public readonly query: unknown; 93 public readonly collection: string; 94 + 95 constructor(collection: string, query: unknown) { 96 super(`Document not found in collection '${collection}'`); 97 this.collection = collection; ··· 107 public readonly operation: string; 108 public readonly collection?: string; 109 public override readonly cause?: Error; 110 + 111 + constructor( 112 + operation: string, 113 + message: string, 114 + collection?: string, 115 + cause?: Error, 116 + ) { 117 super(`${operation} operation failed: ${message}`); 118 this.operation = operation; 119 this.collection = collection; ··· 129 constructor() { 130 super( 131 "Async validation is not currently supported. " + 132 + "Please use synchronous validation schemas.", 133 ); 134 } 135 }
+12 -12
mod.ts
··· 1 - export type { Schema, Infer, Input } from "./types.ts"; 2 - export { 3 - connect, 4 - disconnect, 5 - healthCheck, 6 - startSession, 7 endSession, 8 withTransaction, 9 - type ConnectOptions, 10 - type HealthCheckResult 11 } from "./client/index.ts"; 12 export { Model } from "./model/index.ts"; 13 export { 14 - NozzleError, 15 - ValidationError, 16 ConnectionError, 17 - ConfigurationError, 18 DocumentNotFoundError, 19 OperationError, 20 - AsyncValidationError, 21 } from "./errors.ts"; 22 23 // Re-export MongoDB types that users might need
··· 1 + export type { Infer, Input, Schema } from "./types.ts"; 2 + export { 3 + connect, 4 + type ConnectOptions, 5 + disconnect, 6 endSession, 7 + healthCheck, 8 + type HealthCheckResult, 9 + startSession, 10 withTransaction, 11 } from "./client/index.ts"; 12 export { Model } from "./model/index.ts"; 13 export { 14 + AsyncValidationError, 15 + ConfigurationError, 16 ConnectionError, 17 DocumentNotFoundError, 18 + NozzleError, 19 OperationError, 20 + ValidationError, 21 } from "./errors.ts"; 22 23 // Re-export MongoDB types that users might need
+81 -62
model/core.ts
··· 1 import type { z } from "@zod/zod"; 2 import type { 3 Collection, 4 DeleteResult, 5 Document, 6 Filter, 7 InsertManyResult, 8 - InsertOneResult, 9 InsertOneOptions, 10 - FindOptions, 11 - UpdateOptions, 12 - ReplaceOptions, 13 - FindOneAndUpdateOptions, 14 - FindOneAndReplaceOptions, 15 - DeleteOptions, 16 - CountDocumentsOptions, 17 - AggregateOptions, 18 OptionalUnlessRequiredId, 19 UpdateResult, 20 WithId, 21 - BulkWriteOptions, 22 - UpdateFilter, 23 - ModifyResult, 24 } from "mongodb"; 25 import { ObjectId } from "mongodb"; 26 - import type { Schema, Infer, Input } from "../types.ts"; 27 - import { parse, parsePartial, parseReplace, applyDefaultsForUpsert } from "./validation.ts"; 28 29 /** 30 * Core CRUD operations for the Model class 31 - * 32 * This module contains all basic create, read, update, and delete operations 33 * with automatic Zod validation and transaction support. 34 */ 35 36 /** 37 * Insert a single document into the collection 38 - * 39 * @param collection - MongoDB collection 40 * @param schema - Zod schema for validation 41 * @param data - Document data to insert ··· 46 collection: Collection<Infer<T>>, 47 schema: T, 48 data: Input<T>, 49 - options?: InsertOneOptions 50 ): Promise<InsertOneResult<Infer<T>>> { 51 const validatedData = parse(schema, data); 52 return await collection.insertOne( 53 validatedData as OptionalUnlessRequiredId<Infer<T>>, 54 - options 55 ); 56 } 57 58 /** 59 * Insert multiple documents into the collection 60 - * 61 * @param collection - MongoDB collection 62 * @param schema - Zod schema for validation 63 * @param data - Array of document data to insert ··· 68 collection: Collection<Infer<T>>, 69 schema: T, 70 data: Input<T>[], 71 - options?: BulkWriteOptions 72 ): Promise<InsertManyResult<Infer<T>>> { 73 const validatedData = data.map((item) => parse(schema, item)); 74 return await collection.insertMany( 75 validatedData as OptionalUnlessRequiredId<Infer<T>>[], 76 - options 77 ); 78 } 79 80 /** 81 * Find multiple documents matching the query 82 - * 83 * @param collection - MongoDB collection 84 * @param query - MongoDB query filter 85 * @param options - Find options (including session for transactions) ··· 88 export async function find<T extends Schema>( 89 collection: Collection<Infer<T>>, 90 query: Filter<Infer<T>>, 91 - options?: FindOptions 92 ): Promise<(WithId<Infer<T>>)[]> { 93 return await collection.find(query, options).toArray(); 94 } 95 96 /** 97 * Find a single document matching the query 98 - * 99 * @param collection - MongoDB collection 100 * @param query - MongoDB query filter 101 * @param options - Find options (including session for transactions) ··· 104 export async function findOne<T extends Schema>( 105 collection: Collection<Infer<T>>, 106 query: Filter<Infer<T>>, 107 - options?: FindOptions 108 ): Promise<WithId<Infer<T>> | null> { 109 return await collection.findOne(query, options); 110 } 111 112 /** 113 * Find a document by its MongoDB ObjectId 114 - * 115 * @param collection - MongoDB collection 116 * @param id - Document ID (string or ObjectId) 117 * @param options - Find options (including session for transactions) ··· 120 export async function findById<T extends Schema>( 121 collection: Collection<Infer<T>>, 122 id: string | ObjectId, 123 - options?: FindOptions 124 ): Promise<WithId<Infer<T>> | null> { 125 const objectId = typeof id === "string" ? new ObjectId(id) : id; 126 - return await findOne(collection, { _id: objectId } as Filter<Infer<T>>, options); 127 } 128 129 /** 130 * Update multiple documents matching the query 131 - * 132 * Case handling: 133 * - If upsert: false (or undefined) → Normal update, no defaults applied 134 * - If upsert: true → Defaults added to $setOnInsert for new document creation 135 - * 136 * @param collection - MongoDB collection 137 * @param schema - Zod schema for validation 138 * @param query - MongoDB query filter ··· 145 schema: T, 146 query: Filter<Infer<T>>, 147 data: Partial<z.infer<T>>, 148 - options?: UpdateOptions 149 ): Promise<UpdateResult<Infer<T>>> { 150 const validatedData = parsePartial(schema, data); 151 - let updateDoc: UpdateFilter<Infer<T>> = { $set: validatedData as Partial<Infer<T>> }; 152 - 153 // If this is an upsert, apply defaults using $setOnInsert 154 if (options?.upsert) { 155 updateDoc = applyDefaultsForUpsert(schema, query, updateDoc); 156 } 157 - 158 return await collection.updateMany(query, updateDoc, options); 159 } 160 161 /** 162 * Update a single document matching the query 163 - * 164 * Case handling: 165 * - If upsert: false (or undefined) → Normal update, no defaults applied 166 * - If upsert: true → Defaults added to $setOnInsert for new document creation 167 - * 168 * @param collection - MongoDB collection 169 * @param schema - Zod schema for validation 170 * @param query - MongoDB query filter ··· 177 schema: T, 178 query: Filter<Infer<T>>, 179 data: Partial<z.infer<T>>, 180 - options?: UpdateOptions 181 ): Promise<UpdateResult<Infer<T>>> { 182 const validatedData = parsePartial(schema, data); 183 - let updateDoc: UpdateFilter<Infer<T>> = { $set: validatedData as Partial<Infer<T>> }; 184 - 185 // If this is an upsert, apply defaults using $setOnInsert 186 if (options?.upsert) { 187 updateDoc = applyDefaultsForUpsert(schema, query, updateDoc); 188 } 189 - 190 return await collection.updateOne(query, updateDoc, options); 191 } 192 193 /** 194 * Replace a single document matching the query 195 - * 196 * Case handling: 197 * - If upsert: false (or undefined) → Normal replace on existing doc, no additional defaults 198 * - If upsert: true → Defaults applied via parse() since we're passing a full document 199 - * 200 * Note: For replace operations, defaults are automatically applied by the schema's 201 * parse() function which treats missing fields as candidates for defaults. This works 202 * for both regular replaces and upsert-creates since we're providing a full document. 203 - * 204 * @param collection - MongoDB collection 205 * @param schema - Zod schema for validation 206 * @param query - MongoDB query filter ··· 213 schema: T, 214 query: Filter<Infer<T>>, 215 data: Input<T>, 216 - options?: ReplaceOptions 217 ): Promise<UpdateResult<Infer<T>>> { 218 // parseReplace will apply all schema defaults to missing fields 219 // This works correctly for both regular replaces and upsert-created documents 220 const validatedData = parseReplace(schema, data); 221 - 222 // Remove _id from validatedData for replaceOne (it will use the query's _id) 223 const { _id, ...withoutId } = validatedData as Infer<T> & { _id?: unknown }; 224 return await collection.replaceOne( 225 query, 226 withoutId as Infer<T>, 227 - options 228 ); 229 } 230 231 /** 232 * Find a single document and update it 233 - * 234 * Case handling: 235 * - If upsert: false (or undefined) → Normal update 236 * - If upsert: true → Defaults added to $setOnInsert for new document creation ··· 240 schema: T, 241 query: Filter<Infer<T>>, 242 data: Partial<z.infer<T>>, 243 - options?: FindOneAndUpdateOptions 244 ): Promise<ModifyResult<Infer<T>>> { 245 const validatedData = parsePartial(schema, data); 246 - let updateDoc: UpdateFilter<Infer<T>> = { $set: validatedData as Partial<Infer<T>> }; 247 248 if (options?.upsert) { 249 updateDoc = applyDefaultsForUpsert(schema, query, updateDoc); 250 } 251 252 - const resolvedOptions: FindOneAndUpdateOptions & { includeResultMetadata: true } = { 253 ...(options ?? {}), 254 includeResultMetadata: true as const, 255 }; ··· 259 260 /** 261 * Find a single document and replace it 262 - * 263 * Defaults are applied via parseReplace(), which fills in missing fields 264 * for both normal replacements and upsert-created documents. 265 */ ··· 268 schema: T, 269 query: Filter<Infer<T>>, 270 data: Input<T>, 271 - options?: FindOneAndReplaceOptions 272 ): Promise<ModifyResult<Infer<T>>> { 273 const validatedData = parseReplace(schema, data); 274 const { _id, ...withoutId } = validatedData as Infer<T> & { _id?: unknown }; 275 276 - const resolvedOptions: FindOneAndReplaceOptions & { includeResultMetadata: true } = { 277 ...(options ?? {}), 278 includeResultMetadata: true as const, 279 }; ··· 281 return await collection.findOneAndReplace( 282 query, 283 withoutId as Infer<T>, 284 - resolvedOptions 285 ); 286 } 287 288 /** 289 * Delete multiple documents matching the query 290 - * 291 * @param collection - MongoDB collection 292 * @param query - MongoDB query filter 293 * @param options - Delete options (including session for transactions) ··· 296 export async function deleteMany<T extends Schema>( 297 collection: Collection<Infer<T>>, 298 query: Filter<Infer<T>>, 299 - options?: DeleteOptions 300 ): Promise<DeleteResult> { 301 return await collection.deleteMany(query, options); 302 } 303 304 /** 305 * Delete a single document matching the query 306 - * 307 * @param collection - MongoDB collection 308 * @param query - MongoDB query filter 309 * @param options - Delete options (including session for transactions) ··· 312 export async function deleteOne<T extends Schema>( 313 collection: Collection<Infer<T>>, 314 query: Filter<Infer<T>>, 315 - options?: DeleteOptions 316 ): Promise<DeleteResult> { 317 return await collection.deleteOne(query, options); 318 } 319 320 /** 321 * Count documents matching the query 322 - * 323 * @param collection - MongoDB collection 324 * @param query - MongoDB query filter 325 * @param options - Count options (including session for transactions) ··· 328 export async function count<T extends Schema>( 329 collection: Collection<Infer<T>>, 330 query: Filter<Infer<T>>, 331 - options?: CountDocumentsOptions 332 ): Promise<number> { 333 return await collection.countDocuments(query, options); 334 } 335 336 /** 337 * Execute an aggregation pipeline 338 - * 339 * @param collection - MongoDB collection 340 * @param pipeline - MongoDB aggregation pipeline 341 * @param options - Aggregate options (including session for transactions) ··· 344 export async function aggregate<T extends Schema>( 345 collection: Collection<Infer<T>>, 346 pipeline: Document[], 347 - options?: AggregateOptions 348 ): Promise<Document[]> { 349 return await collection.aggregate(pipeline, options).toArray(); 350 }
··· 1 import type { z } from "@zod/zod"; 2 import 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"; 25 import { ObjectId } from "mongodb"; 26 + import type { Infer, Input, Schema } from "../types.ts"; 27 + import { 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 ··· 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 ··· 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) ··· 93 export 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) ··· 109 export 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) ··· 125 export 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 ··· 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 ··· 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 ··· 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 ··· 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 }; ··· 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 */ ··· 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 }; ··· 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) ··· 315 export 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) ··· 331 export 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) ··· 347 export 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) ··· 363 export 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 }
+90 -60
model/index.ts
··· 1 import type { z } from "@zod/zod"; 2 import 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 - FindOneAndUpdateOptions, 18 - FindOneAndReplaceOptions, 19 - DeleteOptions, 20 - CountDocumentsOptions, 21 - AggregateOptions, 22 - ListIndexesOptions, 23 UpdateResult, 24 WithId, 25 - BulkWriteOptions, 26 - ModifyResult, 27 } from "mongodb"; 28 import type { ObjectId } from "mongodb"; 29 import { getDb } from "../client/connection.ts"; 30 - import type { Schema, Infer, Input, Indexes, ModelDef } from "../types.ts"; 31 import * as core from "./core.ts"; 32 import * as indexes from "./indexes.ts"; 33 import * as pagination from "./pagination.ts"; 34 35 /** 36 * Model class for type-safe MongoDB operations 37 - * 38 * Provides a clean API for CRUD operations, pagination, and index management 39 * with automatic Zod validation and TypeScript type safety. 40 - * 41 * @example 42 * ```ts 43 * const userSchema = z.object({ 44 * name: z.string(), 45 * email: z.string().email(), 46 * }); 47 - * 48 * const UserModel = new Model("users", userSchema); 49 * await UserModel.insertOne({ name: "Alice", email: "alice@example.com" }); 50 * ``` ··· 62 this.schema = definition as T; 63 } 64 this.collection = getDb().collection<Infer<T>>(collectionName); 65 - 66 // Automatically create indexes if they were provided 67 if (this.indexes && this.indexes.length > 0) { 68 // Fire and forget - indexes will be created asynchronously 69 - indexes.syncIndexes(this.collection, this.indexes) 70 } 71 } 72 ··· 76 77 /** 78 * Insert a single document into the collection 79 - * 80 * @param data - Document data to insert 81 * @param options - Insert options (including session for transactions) 82 * @returns Insert result with insertedId 83 */ 84 async insertOne( 85 data: Input<T>, 86 - options?: InsertOneOptions 87 ): Promise<InsertOneResult<Infer<T>>> { 88 return await core.insertOne(this.collection, this.schema, data, options); 89 } 90 91 /** 92 * Insert multiple documents into the collection 93 - * 94 * @param data - Array of document data to insert 95 * @param options - Insert options (including session for transactions) 96 * @returns Insert result with insertedIds 97 */ 98 async insertMany( 99 data: Input<T>[], 100 - options?: BulkWriteOptions 101 ): Promise<InsertManyResult<Infer<T>>> { 102 return await core.insertMany(this.collection, this.schema, data, options); 103 } 104 105 /** 106 * Find multiple documents matching the query 107 - * 108 * @param query - MongoDB query filter 109 * @param options - Find options (including session for transactions) 110 * @returns Array of matching documents 111 */ 112 async find( 113 query: Filter<Infer<T>>, 114 - options?: FindOptions 115 ): Promise<(WithId<Infer<T>>)[]> { 116 return await core.find(this.collection, query, options); 117 } 118 119 /** 120 * Find a single document matching the query 121 - * 122 * @param query - MongoDB query filter 123 * @param options - Find options (including session for transactions) 124 * @returns Matching document or null if not found 125 */ 126 async findOne( 127 query: Filter<Infer<T>>, 128 - options?: FindOptions 129 ): Promise<WithId<Infer<T>> | null> { 130 return await core.findOne(this.collection, query, options); 131 } 132 133 /** 134 * Find a document by its MongoDB ObjectId 135 - * 136 * @param id - Document ID (string or ObjectId) 137 * @param options - Find options (including session for transactions) 138 * @returns Matching document or null if not found 139 */ 140 async findById( 141 id: string | ObjectId, 142 - options?: FindOptions 143 ): Promise<WithId<Infer<T>> | null> { 144 return await core.findById(this.collection, id, options); 145 } 146 147 /** 148 * Update multiple documents matching the query 149 - * 150 * @param query - MongoDB query filter 151 * @param data - Partial data to update 152 * @param options - Update options (including session for transactions) ··· 155 async update( 156 query: Filter<Infer<T>>, 157 data: Partial<z.infer<T>>, 158 - options?: UpdateOptions 159 ): Promise<UpdateResult<Infer<T>>> { 160 - return await core.update(this.collection, this.schema, query, data, options); 161 } 162 163 /** 164 * Update a single document matching the query 165 - * 166 * @param query - MongoDB query filter 167 * @param data - Partial data to update 168 * @param options - Update options (including session for transactions) ··· 171 async updateOne( 172 query: Filter<Infer<T>>, 173 data: Partial<z.infer<T>>, 174 - options?: UpdateOptions 175 ): Promise<UpdateResult<Infer<T>>> { 176 - return await core.updateOne(this.collection, this.schema, query, data, options); 177 } 178 179 /** 180 * Find a single document and update it 181 - * 182 * @param query - MongoDB query filter 183 * @param data - Partial data to update 184 * @param options - FindOneAndUpdate options (including upsert and returnDocument) ··· 187 async findOneAndUpdate( 188 query: Filter<Infer<T>>, 189 data: Partial<z.infer<T>>, 190 - options?: FindOneAndUpdateOptions 191 ): Promise<ModifyResult<Infer<T>>> { 192 - return await core.findOneAndUpdate(this.collection, this.schema, query, data, options); 193 } 194 195 /** 196 * Replace a single document matching the query 197 - * 198 * @param query - MongoDB query filter 199 * @param data - Complete document data for replacement 200 * @param options - Replace options (including session for transactions) ··· 203 async replaceOne( 204 query: Filter<Infer<T>>, 205 data: Input<T>, 206 - options?: ReplaceOptions 207 ): Promise<UpdateResult<Infer<T>>> { 208 - return await core.replaceOne(this.collection, this.schema, query, data, options); 209 } 210 211 /** 212 * Find a single document and replace it 213 - * 214 * @param query - MongoDB query filter 215 * @param data - Complete document data for replacement 216 * @param options - FindOneAndReplace options (including upsert and returnDocument) ··· 219 async findOneAndReplace( 220 query: Filter<Infer<T>>, 221 data: Input<T>, 222 - options?: FindOneAndReplaceOptions 223 ): Promise<ModifyResult<Infer<T>>> { 224 - return await core.findOneAndReplace(this.collection, this.schema, query, data, options); 225 } 226 227 /** 228 * Delete multiple documents matching the query 229 - * 230 * @param query - MongoDB query filter 231 * @param options - Delete options (including session for transactions) 232 * @returns Delete result 233 */ 234 async delete( 235 query: Filter<Infer<T>>, 236 - options?: DeleteOptions 237 ): Promise<DeleteResult> { 238 return await core.deleteMany(this.collection, query, options); 239 } 240 241 /** 242 * Delete a single document matching the query 243 - * 244 * @param query - MongoDB query filter 245 * @param options - Delete options (including session for transactions) 246 * @returns Delete result 247 */ 248 async deleteOne( 249 query: Filter<Infer<T>>, 250 - options?: DeleteOptions 251 ): Promise<DeleteResult> { 252 return await core.deleteOne(this.collection, query, options); 253 } 254 255 /** 256 * Count documents matching the query 257 - * 258 * @param query - MongoDB query filter 259 * @param options - Count options (including session for transactions) 260 * @returns Number of matching documents 261 */ 262 async count( 263 query: Filter<Infer<T>>, 264 - options?: CountDocumentsOptions 265 ): Promise<number> { 266 return await core.count(this.collection, query, options); 267 } 268 269 /** 270 * Execute an aggregation pipeline 271 - * 272 * @param pipeline - MongoDB aggregation pipeline 273 * @param options - Aggregate options (including session for transactions) 274 * @returns Array of aggregation results 275 */ 276 async aggregate( 277 pipeline: Document[], 278 - options?: AggregateOptions 279 ): Promise<Document[]> { 280 return await core.aggregate(this.collection, pipeline, options); 281 } ··· 286 287 /** 288 * Find documents with pagination support 289 - * 290 * @param query - MongoDB query filter 291 * @param options - Pagination options (skip, limit, sort) 292 * @returns Array of matching documents ··· 304 305 /** 306 * Create a single index on the collection 307 - * 308 * @param keys - Index specification (e.g., { email: 1 } or { name: "text" }) 309 * @param options - Index creation options (unique, sparse, expireAfterSeconds, etc.) 310 * @returns The name of the created index ··· 318 319 /** 320 * Create multiple indexes on the collection 321 - * 322 * @param indexes - Array of index descriptions 323 * @param options - Index creation options 324 * @returns Array of index names created ··· 332 333 /** 334 * Drop a single index from the collection 335 - * 336 * @param index - Index name or specification 337 * @param options - Drop index options 338 */ ··· 345 346 /** 347 * Drop all indexes from the collection (except _id index) 348 - * 349 * @param options - Drop index options 350 */ 351 async dropIndexes(options?: DropIndexesOptions): Promise<void> { ··· 354 355 /** 356 * List all indexes on the collection 357 - * 358 * @param options - List indexes options 359 * @returns Array of index information 360 */ ··· 366 367 /** 368 * Get index information by name 369 - * 370 * @param indexName - Name of the index 371 * @returns Index description or null if not found 372 */ ··· 376 377 /** 378 * Check if an index exists 379 - * 380 * @param indexName - Name of the index 381 * @returns True if index exists, false otherwise 382 */ ··· 386 387 /** 388 * Synchronize indexes - create indexes if they don't exist, update if they differ 389 - * 390 * This is useful for ensuring indexes match your schema definition 391 - * 392 * @param indexes - Array of index descriptions to synchronize 393 * @param options - Options for index creation 394 * @returns Array of index names that were created
··· 1 import type { z } from "@zod/zod"; 2 import type { 3 + AggregateOptions, 4 + BulkWriteOptions, 5 Collection, 6 + CountDocumentsOptions, 7 CreateIndexesOptions, 8 + DeleteOptions, 9 DeleteResult, 10 Document, 11 DropIndexesOptions, 12 Filter, 13 + FindOneAndReplaceOptions, 14 + FindOneAndUpdateOptions, 15 + FindOptions, 16 IndexDescription, 17 IndexSpecification, 18 InsertManyResult, 19 + InsertOneOptions, 20 InsertOneResult, 21 + ListIndexesOptions, 22 + ModifyResult, 23 ReplaceOptions, 24 + UpdateOptions, 25 UpdateResult, 26 WithId, 27 } from "mongodb"; 28 import type { ObjectId } from "mongodb"; 29 import { getDb } from "../client/connection.ts"; 30 + import type { Indexes, Infer, Input, ModelDef, Schema } from "../types.ts"; 31 import * as core from "./core.ts"; 32 import * as indexes from "./indexes.ts"; 33 import * as pagination from "./pagination.ts"; 34 35 /** 36 * Model class for type-safe MongoDB operations 37 + * 38 * Provides a clean API for CRUD operations, pagination, and index management 39 * with automatic Zod validation and TypeScript type safety. 40 + * 41 * @example 42 * ```ts 43 * const userSchema = z.object({ 44 * name: z.string(), 45 * email: z.string().email(), 46 * }); 47 + * 48 * const UserModel = new Model("users", userSchema); 49 * await UserModel.insertOne({ name: "Alice", email: "alice@example.com" }); 50 * ``` ··· 62 this.schema = definition as T; 63 } 64 this.collection = getDb().collection<Infer<T>>(collectionName); 65 + 66 // Automatically create indexes if they were provided 67 if (this.indexes && this.indexes.length > 0) { 68 // Fire and forget - indexes will be created asynchronously 69 + indexes.syncIndexes(this.collection, this.indexes); 70 } 71 } 72 ··· 76 77 /** 78 * Insert a single document into the collection 79 + * 80 * @param data - Document data to insert 81 * @param options - Insert options (including session for transactions) 82 * @returns Insert result with insertedId 83 */ 84 async insertOne( 85 data: Input<T>, 86 + options?: InsertOneOptions, 87 ): Promise<InsertOneResult<Infer<T>>> { 88 return await core.insertOne(this.collection, this.schema, data, options); 89 } 90 91 /** 92 * Insert multiple documents into the collection 93 + * 94 * @param data - Array of document data to insert 95 * @param options - Insert options (including session for transactions) 96 * @returns Insert result with insertedIds 97 */ 98 async insertMany( 99 data: Input<T>[], 100 + options?: BulkWriteOptions, 101 ): Promise<InsertManyResult<Infer<T>>> { 102 return await core.insertMany(this.collection, this.schema, data, options); 103 } 104 105 /** 106 * Find multiple documents matching the query 107 + * 108 * @param query - MongoDB query filter 109 * @param options - Find options (including session for transactions) 110 * @returns Array of matching documents 111 */ 112 async find( 113 query: Filter<Infer<T>>, 114 + options?: FindOptions, 115 ): Promise<(WithId<Infer<T>>)[]> { 116 return await core.find(this.collection, query, options); 117 } 118 119 /** 120 * Find a single document matching the query 121 + * 122 * @param query - MongoDB query filter 123 * @param options - Find options (including session for transactions) 124 * @returns Matching document or null if not found 125 */ 126 async findOne( 127 query: Filter<Infer<T>>, 128 + options?: FindOptions, 129 ): Promise<WithId<Infer<T>> | null> { 130 return await core.findOne(this.collection, query, options); 131 } 132 133 /** 134 * Find a document by its MongoDB ObjectId 135 + * 136 * @param id - Document ID (string or ObjectId) 137 * @param options - Find options (including session for transactions) 138 * @returns Matching document or null if not found 139 */ 140 async findById( 141 id: string | ObjectId, 142 + options?: FindOptions, 143 ): Promise<WithId<Infer<T>> | null> { 144 return await core.findById(this.collection, id, options); 145 } 146 147 /** 148 * Update multiple documents matching the query 149 + * 150 * @param query - MongoDB query filter 151 * @param data - Partial data to update 152 * @param options - Update options (including session for transactions) ··· 155 async update( 156 query: Filter<Infer<T>>, 157 data: Partial<z.infer<T>>, 158 + options?: UpdateOptions, 159 ): Promise<UpdateResult<Infer<T>>> { 160 + return await core.update( 161 + this.collection, 162 + this.schema, 163 + query, 164 + data, 165 + options, 166 + ); 167 } 168 169 /** 170 * Update a single document matching the query 171 + * 172 * @param query - MongoDB query filter 173 * @param data - Partial data to update 174 * @param options - Update options (including session for transactions) ··· 177 async updateOne( 178 query: Filter<Infer<T>>, 179 data: Partial<z.infer<T>>, 180 + options?: UpdateOptions, 181 ): Promise<UpdateResult<Infer<T>>> { 182 + return await core.updateOne( 183 + this.collection, 184 + this.schema, 185 + query, 186 + data, 187 + options, 188 + ); 189 } 190 191 /** 192 * Find a single document and update it 193 + * 194 * @param query - MongoDB query filter 195 * @param data - Partial data to update 196 * @param options - FindOneAndUpdate options (including upsert and returnDocument) ··· 199 async findOneAndUpdate( 200 query: Filter<Infer<T>>, 201 data: Partial<z.infer<T>>, 202 + options?: FindOneAndUpdateOptions, 203 ): Promise<ModifyResult<Infer<T>>> { 204 + return await core.findOneAndUpdate( 205 + this.collection, 206 + this.schema, 207 + query, 208 + data, 209 + options, 210 + ); 211 } 212 213 /** 214 * Replace a single document matching the query 215 + * 216 * @param query - MongoDB query filter 217 * @param data - Complete document data for replacement 218 * @param options - Replace options (including session for transactions) ··· 221 async replaceOne( 222 query: Filter<Infer<T>>, 223 data: Input<T>, 224 + options?: ReplaceOptions, 225 ): Promise<UpdateResult<Infer<T>>> { 226 + return await core.replaceOne( 227 + this.collection, 228 + this.schema, 229 + query, 230 + data, 231 + options, 232 + ); 233 } 234 235 /** 236 * Find a single document and replace it 237 + * 238 * @param query - MongoDB query filter 239 * @param data - Complete document data for replacement 240 * @param options - FindOneAndReplace options (including upsert and returnDocument) ··· 243 async findOneAndReplace( 244 query: Filter<Infer<T>>, 245 data: Input<T>, 246 + options?: FindOneAndReplaceOptions, 247 ): Promise<ModifyResult<Infer<T>>> { 248 + return await core.findOneAndReplace( 249 + this.collection, 250 + this.schema, 251 + query, 252 + data, 253 + options, 254 + ); 255 } 256 257 /** 258 * Delete multiple documents matching the query 259 + * 260 * @param query - MongoDB query filter 261 * @param options - Delete options (including session for transactions) 262 * @returns Delete result 263 */ 264 async delete( 265 query: Filter<Infer<T>>, 266 + options?: DeleteOptions, 267 ): Promise<DeleteResult> { 268 return await core.deleteMany(this.collection, query, options); 269 } 270 271 /** 272 * Delete a single document matching the query 273 + * 274 * @param query - MongoDB query filter 275 * @param options - Delete options (including session for transactions) 276 * @returns Delete result 277 */ 278 async deleteOne( 279 query: Filter<Infer<T>>, 280 + options?: DeleteOptions, 281 ): Promise<DeleteResult> { 282 return await core.deleteOne(this.collection, query, options); 283 } 284 285 /** 286 * Count documents matching the query 287 + * 288 * @param query - MongoDB query filter 289 * @param options - Count options (including session for transactions) 290 * @returns Number of matching documents 291 */ 292 async count( 293 query: Filter<Infer<T>>, 294 + options?: CountDocumentsOptions, 295 ): Promise<number> { 296 return await core.count(this.collection, query, options); 297 } 298 299 /** 300 * Execute an aggregation pipeline 301 + * 302 * @param pipeline - MongoDB aggregation pipeline 303 * @param options - Aggregate options (including session for transactions) 304 * @returns Array of aggregation results 305 */ 306 async aggregate( 307 pipeline: Document[], 308 + options?: AggregateOptions, 309 ): Promise<Document[]> { 310 return await core.aggregate(this.collection, pipeline, options); 311 } ··· 316 317 /** 318 * Find documents with pagination support 319 + * 320 * @param query - MongoDB query filter 321 * @param options - Pagination options (skip, limit, sort) 322 * @returns Array of matching documents ··· 334 335 /** 336 * Create a single index on the collection 337 + * 338 * @param keys - Index specification (e.g., { email: 1 } or { name: "text" }) 339 * @param options - Index creation options (unique, sparse, expireAfterSeconds, etc.) 340 * @returns The name of the created index ··· 348 349 /** 350 * Create multiple indexes on the collection 351 + * 352 * @param indexes - Array of index descriptions 353 * @param options - Index creation options 354 * @returns Array of index names created ··· 362 363 /** 364 * Drop a single index from the collection 365 + * 366 * @param index - Index name or specification 367 * @param options - Drop index options 368 */ ··· 375 376 /** 377 * Drop all indexes from the collection (except _id index) 378 + * 379 * @param options - Drop index options 380 */ 381 async dropIndexes(options?: DropIndexesOptions): Promise<void> { ··· 384 385 /** 386 * List all indexes on the collection 387 + * 388 * @param options - List indexes options 389 * @returns Array of index information 390 */ ··· 396 397 /** 398 * Get index information by name 399 + * 400 * @param indexName - Name of the index 401 * @returns Index description or null if not found 402 */ ··· 406 407 /** 408 * Check if an index exists 409 + * 410 * @param indexName - Name of the index 411 * @returns True if index exists, false otherwise 412 */ ··· 416 417 /** 418 * Synchronize indexes - create indexes if they don't exist, update if they differ 419 + * 420 * This is useful for ensuring indexes match your schema definition 421 + * 422 * @param indexes - Array of index descriptions to synchronize 423 * @param options - Options for index creation 424 * @returns Array of index names that were created
+15 -15
model/indexes.ts
··· 6 IndexSpecification, 7 ListIndexesOptions, 8 } from "mongodb"; 9 - import type { Schema, Infer } from "../types.ts"; 10 11 /** 12 * Index management operations for the Model class 13 - * 14 * This module contains all index-related operations including creation, 15 * deletion, listing, and synchronization of indexes. 16 */ 17 18 /** 19 * Create a single index on the collection 20 - * 21 * @param collection - MongoDB collection 22 * @param keys - Index specification (e.g., { email: 1 } or { name: "text" }) 23 * @param options - Index creation options (unique, sparse, expireAfterSeconds, etc.) ··· 33 34 /** 35 * Create multiple indexes on the collection 36 - * 37 * @param collection - MongoDB collection 38 * @param indexes - Array of index descriptions 39 * @param options - Index creation options ··· 49 50 /** 51 * Drop a single index from the collection 52 - * 53 * @param collection - MongoDB collection 54 * @param index - Index name or specification 55 * @param options - Drop index options ··· 64 65 /** 66 * Drop all indexes from the collection (except _id index) 67 - * 68 * @param collection - MongoDB collection 69 * @param options - Drop index options 70 */ 71 export async function dropIndexes<T extends Schema>( 72 collection: Collection<Infer<T>>, 73 - options?: DropIndexesOptions 74 ): Promise<void> { 75 await collection.dropIndexes(options); 76 } 77 78 /** 79 * List all indexes on the collection 80 - * 81 * @param collection - MongoDB collection 82 * @param options - List indexes options 83 * @returns Array of index information ··· 92 93 /** 94 * Get index information by name 95 - * 96 * @param collection - MongoDB collection 97 * @param indexName - Name of the index 98 * @returns Index description or null if not found 99 */ 100 export async function getIndex<T extends Schema>( 101 collection: Collection<Infer<T>>, 102 - indexName: string 103 ): Promise<IndexDescription | null> { 104 const indexes = await listIndexes(collection); 105 return indexes.find((idx) => idx.name === indexName) || null; ··· 107 108 /** 109 * Check if an index exists 110 - * 111 * @param collection - MongoDB collection 112 * @param indexName - Name of the index 113 * @returns True if index exists, false otherwise 114 */ 115 export async function indexExists<T extends Schema>( 116 collection: Collection<Infer<T>>, 117 - indexName: string 118 ): Promise<boolean> { 119 const index = await getIndex(collection, indexName); 120 return index !== null; ··· 122 123 /** 124 * Synchronize indexes - create indexes if they don't exist, update if they differ 125 - * 126 * This is useful for ensuring indexes match your schema definition 127 - * 128 * @param collection - MongoDB collection 129 * @param indexes - Array of index descriptions to synchronize 130 * @param options - Options for index creation ··· 167 168 /** 169 * Generate index name from key specification 170 - * 171 * @param keys - Index specification 172 * @returns Generated index name 173 */
··· 6 IndexSpecification, 7 ListIndexesOptions, 8 } from "mongodb"; 9 + import type { Infer, Schema } from "../types.ts"; 10 11 /** 12 * Index management operations for the Model class 13 + * 14 * This module contains all index-related operations including creation, 15 * deletion, listing, and synchronization of indexes. 16 */ 17 18 /** 19 * Create a single index on the collection 20 + * 21 * @param collection - MongoDB collection 22 * @param keys - Index specification (e.g., { email: 1 } or { name: "text" }) 23 * @param options - Index creation options (unique, sparse, expireAfterSeconds, etc.) ··· 33 34 /** 35 * Create multiple indexes on the collection 36 + * 37 * @param collection - MongoDB collection 38 * @param indexes - Array of index descriptions 39 * @param options - Index creation options ··· 49 50 /** 51 * Drop a single index from the collection 52 + * 53 * @param collection - MongoDB collection 54 * @param index - Index name or specification 55 * @param options - Drop index options ··· 64 65 /** 66 * Drop all indexes from the collection (except _id index) 67 + * 68 * @param collection - MongoDB collection 69 * @param options - Drop index options 70 */ 71 export async function dropIndexes<T extends Schema>( 72 collection: Collection<Infer<T>>, 73 + options?: DropIndexesOptions, 74 ): Promise<void> { 75 await collection.dropIndexes(options); 76 } 77 78 /** 79 * List all indexes on the collection 80 + * 81 * @param collection - MongoDB collection 82 * @param options - List indexes options 83 * @returns Array of index information ··· 92 93 /** 94 * Get index information by name 95 + * 96 * @param collection - MongoDB collection 97 * @param indexName - Name of the index 98 * @returns Index description or null if not found 99 */ 100 export async function getIndex<T extends Schema>( 101 collection: Collection<Infer<T>>, 102 + indexName: string, 103 ): Promise<IndexDescription | null> { 104 const indexes = await listIndexes(collection); 105 return indexes.find((idx) => idx.name === indexName) || null; ··· 107 108 /** 109 * Check if an index exists 110 + * 111 * @param collection - MongoDB collection 112 * @param indexName - Name of the index 113 * @returns True if index exists, false otherwise 114 */ 115 export async function indexExists<T extends Schema>( 116 collection: Collection<Infer<T>>, 117 + indexName: string, 118 ): Promise<boolean> { 119 const index = await getIndex(collection, indexName); 120 return index !== null; ··· 122 123 /** 124 * Synchronize indexes - create indexes if they don't exist, update if they differ 125 + * 126 * This is useful for ensuring indexes match your schema definition 127 + * 128 * @param collection - MongoDB collection 129 * @param indexes - Array of index descriptions to synchronize 130 * @param options - Options for index creation ··· 167 168 /** 169 * Generate index name from key specification 170 + * 171 * @param keys - Index specification 172 * @returns Generated index name 173 */
+6 -11
model/pagination.ts
··· 1 - import type { 2 - Collection, 3 - Document, 4 - Filter, 5 - WithId, 6 - } from "mongodb"; 7 - import type { Schema, Infer } from "../types.ts"; 8 9 /** 10 * Pagination operations for the Model class 11 - * 12 * This module contains pagination-related functionality for finding documents 13 * with skip, limit, and sort options. 14 */ 15 16 /** 17 * Find documents with pagination support 18 - * 19 * @param collection - MongoDB collection 20 * @param query - MongoDB query filter 21 * @param options - Pagination options (skip, limit, sort) 22 * @returns Array of matching documents 23 - * 24 * @example 25 * ```ts 26 - * const users = await findPaginated(collection, 27 * { age: { $gte: 18 } }, 28 * { skip: 0, limit: 10, sort: { createdAt: -1 } } 29 * );
··· 1 + import type { Collection, Document, Filter, WithId } from "mongodb"; 2 + import type { Infer, Schema } from "../types.ts"; 3 4 /** 5 * Pagination operations for the Model class 6 + * 7 * This module contains pagination-related functionality for finding documents 8 * with skip, limit, and sort options. 9 */ 10 11 /** 12 * Find documents with pagination support 13 + * 14 * @param collection - MongoDB collection 15 * @param query - MongoDB query filter 16 * @param options - Pagination options (skip, limit, sort) 17 * @returns Array of matching documents 18 + * 19 * @example 20 * ```ts 21 + * const users = await findPaginated(collection, 22 * { age: { $gte: 18 } }, 23 * { skip: 0, limit: 10, sort: { createdAt: -1 } } 24 * );
+86 -51
model/validation.ts
··· 1 import type { z } from "@zod/zod"; 2 - import type { Schema, Infer, Input } from "../types.ts"; 3 - import { ValidationError, AsyncValidationError } from "../errors.ts"; 4 - import type { Document, UpdateFilter, Filter } from "mongodb"; 5 6 /** 7 * Validate data for insert operations using Zod schema 8 - * 9 * @param schema - Zod schema to validate against 10 * @param data - Data to validate 11 * @returns Validated and typed data ··· 14 */ 15 export function parse<T extends Schema>(schema: T, data: Input<T>): Infer<T> { 16 const result = schema.safeParse(data); 17 - 18 // Check for async validation 19 if (result instanceof Promise) { 20 throw new AsyncValidationError(); 21 } 22 - 23 if (!result.success) { 24 throw new ValidationError(result.error.issues, "insert"); 25 } ··· 28 29 /** 30 * Validate partial data for update operations using Zod schema 31 - * 32 * Important: This function only validates the fields that are provided in the data object. 33 * Unlike parse(), this function does NOT apply defaults for missing fields because 34 * in an update context, missing fields should remain unchanged in the database. 35 - * 36 * @param schema - Zod schema to validate against 37 * @param data - Partial data to validate 38 * @returns Validated and typed partial data (only fields present in input) ··· 43 schema: T, 44 data: Partial<z.infer<T>>, 45 ): Partial<z.infer<T>> { 46 // Get the list of fields actually provided in the input 47 const inputKeys = Object.keys(data); 48 - 49 - const result = schema.partial().safeParse(data); 50 - 51 // Check for async validation 52 if (result instanceof Promise) { 53 throw new AsyncValidationError(); 54 } 55 - 56 if (!result.success) { 57 throw new ValidationError(result.error.issues, "update"); 58 } 59 - 60 // Filter the result to only include fields that were in the input 61 // This prevents defaults from being applied to fields that weren't provided 62 const filtered: Record<string, unknown> = {}; 63 for (const key of inputKeys) { 64 - if (key in result.data) { 65 filtered[key] = (result.data as Record<string, unknown>)[key]; 66 } 67 } 68 - 69 return filtered as Partial<z.infer<T>>; 70 } 71 72 /** 73 * Validate data for replace operations using Zod schema 74 - * 75 * @param schema - Zod schema to validate against 76 * @param data - Data to validate 77 * @returns Validated and typed data 78 * @throws {ValidationError} If validation fails 79 * @throws {AsyncValidationError} If async validation is detected 80 */ 81 - export function parseReplace<T extends Schema>(schema: T, data: Input<T>): Infer<T> { 82 const result = schema.safeParse(data); 83 - 84 // Check for async validation 85 if (result instanceof Promise) { 86 throw new AsyncValidationError(); 87 } 88 - 89 if (!result.success) { 90 throw new ValidationError(result.error.issues, "replace"); 91 } ··· 95 /** 96 * Extract default values from a Zod schema 97 * This parses an empty object through the schema to get all defaults applied 98 - * 99 * @param schema - Zod schema to extract defaults from 100 * @returns Object containing all default values from the schema 101 */ 102 - export function extractDefaults<T extends Schema>(schema: T): Partial<Infer<T>> { 103 try { 104 // Make all fields optional, then parse empty object to trigger defaults 105 // This allows us to see which fields get default values 106 - const partialSchema = schema.partial(); 107 const result = partialSchema.safeParse({}); 108 - 109 if (result instanceof Promise) { 110 // Cannot extract defaults from async schemas 111 return {}; 112 } 113 - 114 // If successful, the result contains all fields that have defaults 115 // Only include fields that were actually added (have values) 116 if (!result.success) { 117 return {}; 118 } 119 - 120 // Filter to only include fields that got values from defaults 121 // (not undefined, which indicates no default) 122 const defaults: Record<string, unknown> = {}; 123 const data = result.data as Record<string, unknown>; 124 - 125 for (const [key, value] of Object.entries(data)) { 126 if (value !== undefined) { 127 defaults[key] = value; 128 } 129 } 130 - 131 return defaults as Partial<Infer<T>>; 132 } catch { 133 return {}; ··· 137 /** 138 * Get all field paths mentioned in an update filter object 139 * This includes fields in $set, $unset, $inc, $push, etc. 140 - * 141 * @param update - MongoDB update filter 142 * @returns Set of field paths that are being modified 143 */ 144 function getModifiedFields(update: UpdateFilter<Document>): Set<string> { 145 const fields = new Set<string>(); 146 - 147 - // Operators that modify fields 148 - const operators = [ 149 - '$set', '$unset', '$inc', '$mul', '$rename', '$min', '$max', 150 - '$currentDate', '$push', '$pull', '$addToSet', '$pop', '$bit', 151 - '$setOnInsert', 152 - ]; 153 - 154 - for (const op of operators) { 155 - if (update[op] && typeof update[op] === 'object') { 156 // Add all field names from this operator 157 for (const field of Object.keys(update[op] as Document)) { 158 fields.add(field); 159 } 160 } 161 } 162 - 163 return fields; 164 } 165 ··· 209 210 /** 211 * Apply schema defaults to an update operation using $setOnInsert 212 - * 213 * This is used for upsert operations to ensure defaults are applied when 214 * a new document is created, but not when updating an existing document. 215 - * 216 * For each default field: 217 * - If the field is NOT mentioned in any update operator ($set, $inc, etc.) 218 * - If the field is NOT fixed by an equality clause in the query filter 219 * - Add it to $setOnInsert so it's only applied on insert 220 - * 221 * @param schema - Zod schema with defaults 222 * @param query - MongoDB query filter 223 * @param update - MongoDB update filter ··· 226 export function applyDefaultsForUpsert<T extends Schema>( 227 schema: T, 228 query: Filter<Infer<T>>, 229 - update: UpdateFilter<Infer<T>> 230 ): UpdateFilter<Infer<T>> { 231 // Extract defaults from schema 232 const defaults = extractDefaults(schema); 233 - 234 // If no defaults, return update unchanged 235 if (Object.keys(defaults).length === 0) { 236 return update; 237 } 238 - 239 // Get fields that are already being modified 240 const modifiedFields = getModifiedFields(update as UpdateFilter<Document>); 241 const filterEqualityFields = getEqualityFields(query as Filter<Document>); 242 - 243 // Build $setOnInsert with defaults for unmodified fields 244 const setOnInsert: Partial<Infer<T>> = {}; 245 - 246 for (const [field, value] of Object.entries(defaults)) { 247 // Only add default if field is not already being modified or fixed by filter equality 248 if (!modifiedFields.has(field) && !filterEqualityFields.has(field)) { 249 setOnInsert[field as keyof Infer<T>] = value as Infer<T>[keyof Infer<T>]; 250 } 251 } 252 - 253 // If there are defaults to add, merge them into $setOnInsert 254 if (Object.keys(setOnInsert).length > 0) { 255 return { 256 ...update, 257 $setOnInsert: { 258 ...(update.$setOnInsert || {}), 259 - ...setOnInsert 260 - } as Partial<Infer<T>> 261 }; 262 } 263 - 264 return update; 265 }
··· 1 import type { z } from "@zod/zod"; 2 + import type { Infer, Input, Schema } from "../types.ts"; 3 + import { AsyncValidationError, ValidationError } from "../errors.ts"; 4 + import type { Document, Filter, UpdateFilter } from "mongodb"; 5 + 6 + // Cache frequently reused schema transformations to avoid repeated allocations 7 + const partialSchemaCache = new WeakMap<Schema, z.ZodTypeAny>(); 8 + const defaultsCache = new WeakMap<Schema, Record<string, unknown>>(); 9 + const updateOperators = [ 10 + "$set", 11 + "$unset", 12 + "$inc", 13 + "$mul", 14 + "$rename", 15 + "$min", 16 + "$max", 17 + "$currentDate", 18 + "$push", 19 + "$pull", 20 + "$addToSet", 21 + "$pop", 22 + "$bit", 23 + "$setOnInsert", 24 + ]; 25 + 26 + function getPartialSchema(schema: Schema): z.ZodTypeAny { 27 + const cached = partialSchemaCache.get(schema); 28 + if (cached) return cached; 29 + const partial = schema.partial(); 30 + partialSchemaCache.set(schema, partial); 31 + return partial; 32 + } 33 34 /** 35 * Validate data for insert operations using Zod schema 36 + * 37 * @param schema - Zod schema to validate against 38 * @param data - Data to validate 39 * @returns Validated and typed data ··· 42 */ 43 export function parse<T extends Schema>(schema: T, data: Input<T>): Infer<T> { 44 const result = schema.safeParse(data); 45 + 46 // Check for async validation 47 if (result instanceof Promise) { 48 throw new AsyncValidationError(); 49 } 50 + 51 if (!result.success) { 52 throw new ValidationError(result.error.issues, "insert"); 53 } ··· 56 57 /** 58 * Validate partial data for update operations using Zod schema 59 + * 60 * Important: This function only validates the fields that are provided in the data object. 61 * Unlike parse(), this function does NOT apply defaults for missing fields because 62 * in an update context, missing fields should remain unchanged in the database. 63 + * 64 * @param schema - Zod schema to validate against 65 * @param data - Partial data to validate 66 * @returns Validated and typed partial data (only fields present in input) ··· 71 schema: T, 72 data: Partial<z.infer<T>>, 73 ): Partial<z.infer<T>> { 74 + if (!data || Object.keys(data).length === 0) { 75 + return {}; 76 + } 77 + 78 // Get the list of fields actually provided in the input 79 const inputKeys = Object.keys(data); 80 + 81 + const result = getPartialSchema(schema).safeParse(data); 82 + 83 // Check for async validation 84 if (result instanceof Promise) { 85 throw new AsyncValidationError(); 86 } 87 + 88 if (!result.success) { 89 throw new ValidationError(result.error.issues, "update"); 90 } 91 + 92 // Filter the result to only include fields that were in the input 93 // This prevents defaults from being applied to fields that weren't provided 94 const filtered: Record<string, unknown> = {}; 95 for (const key of inputKeys) { 96 + if (key in (result.data as Record<string, unknown>)) { 97 filtered[key] = (result.data as Record<string, unknown>)[key]; 98 } 99 } 100 + 101 return filtered as Partial<z.infer<T>>; 102 } 103 104 /** 105 * Validate data for replace operations using Zod schema 106 + * 107 * @param schema - Zod schema to validate against 108 * @param data - Data to validate 109 * @returns Validated and typed data 110 * @throws {ValidationError} If validation fails 111 * @throws {AsyncValidationError} If async validation is detected 112 */ 113 + export function parseReplace<T extends Schema>( 114 + schema: T, 115 + data: Input<T>, 116 + ): Infer<T> { 117 const result = schema.safeParse(data); 118 + 119 // Check for async validation 120 if (result instanceof Promise) { 121 throw new AsyncValidationError(); 122 } 123 + 124 if (!result.success) { 125 throw new ValidationError(result.error.issues, "replace"); 126 } ··· 130 /** 131 * Extract default values from a Zod schema 132 * This parses an empty object through the schema to get all defaults applied 133 + * 134 * @param schema - Zod schema to extract defaults from 135 * @returns Object containing all default values from the schema 136 */ 137 + export function extractDefaults<T extends Schema>( 138 + schema: T, 139 + ): Partial<Infer<T>> { 140 + const cached = defaultsCache.get(schema); 141 + if (cached) { 142 + return cached as Partial<Infer<T>>; 143 + } 144 + 145 try { 146 // Make all fields optional, then parse empty object to trigger defaults 147 // This allows us to see which fields get default values 148 + const partialSchema = getPartialSchema(schema); 149 const result = partialSchema.safeParse({}); 150 + 151 if (result instanceof Promise) { 152 // Cannot extract defaults from async schemas 153 return {}; 154 } 155 + 156 // If successful, the result contains all fields that have defaults 157 // Only include fields that were actually added (have values) 158 if (!result.success) { 159 return {}; 160 } 161 + 162 // Filter to only include fields that got values from defaults 163 // (not undefined, which indicates no default) 164 const defaults: Record<string, unknown> = {}; 165 const data = result.data as Record<string, unknown>; 166 + 167 for (const [key, value] of Object.entries(data)) { 168 if (value !== undefined) { 169 defaults[key] = value; 170 } 171 } 172 + defaultsCache.set(schema, defaults as Partial<Infer<Schema>>); 173 return defaults as Partial<Infer<T>>; 174 } catch { 175 return {}; ··· 179 /** 180 * Get all field paths mentioned in an update filter object 181 * This includes fields in $set, $unset, $inc, $push, etc. 182 + * 183 * @param update - MongoDB update filter 184 * @returns Set of field paths that are being modified 185 */ 186 function getModifiedFields(update: UpdateFilter<Document>): Set<string> { 187 const fields = new Set<string>(); 188 + 189 + for (const op of updateOperators) { 190 + if (update[op] && typeof update[op] === "object") { 191 // Add all field names from this operator 192 for (const field of Object.keys(update[op] as Document)) { 193 fields.add(field); 194 } 195 } 196 } 197 + 198 return fields; 199 } 200 ··· 244 245 /** 246 * Apply schema defaults to an update operation using $setOnInsert 247 + * 248 * This is used for upsert operations to ensure defaults are applied when 249 * a new document is created, but not when updating an existing document. 250 + * 251 * For each default field: 252 * - If the field is NOT mentioned in any update operator ($set, $inc, etc.) 253 * - If the field is NOT fixed by an equality clause in the query filter 254 * - Add it to $setOnInsert so it's only applied on insert 255 + * 256 * @param schema - Zod schema with defaults 257 * @param query - MongoDB query filter 258 * @param update - MongoDB update filter ··· 261 export function applyDefaultsForUpsert<T extends Schema>( 262 schema: T, 263 query: Filter<Infer<T>>, 264 + update: UpdateFilter<Infer<T>>, 265 ): UpdateFilter<Infer<T>> { 266 // Extract defaults from schema 267 const defaults = extractDefaults(schema); 268 + 269 // If no defaults, return update unchanged 270 if (Object.keys(defaults).length === 0) { 271 return update; 272 } 273 + 274 // Get fields that are already being modified 275 const modifiedFields = getModifiedFields(update as UpdateFilter<Document>); 276 const filterEqualityFields = getEqualityFields(query as Filter<Document>); 277 + 278 // Build $setOnInsert with defaults for unmodified fields 279 const setOnInsert: Partial<Infer<T>> = {}; 280 + 281 for (const [field, value] of Object.entries(defaults)) { 282 // Only add default if field is not already being modified or fixed by filter equality 283 if (!modifiedFields.has(field) && !filterEqualityFields.has(field)) { 284 setOnInsert[field as keyof Infer<T>] = value as Infer<T>[keyof Infer<T>]; 285 } 286 } 287 + 288 // If there are defaults to add, merge them into $setOnInsert 289 if (Object.keys(setOnInsert).length > 0) { 290 return { 291 ...update, 292 $setOnInsert: { 293 ...(update.$setOnInsert || {}), 294 + ...setOnInsert, 295 + } as Partial<Infer<T>>, 296 }; 297 } 298 + 299 return update; 300 }
+42 -37
tests/connection_test.ts
··· 1 import { assert, assertEquals, assertExists } from "@std/assert"; 2 - import { connect, disconnect, healthCheck, type ConnectOptions } from "../mod.ts"; 3 import { MongoMemoryServer } from "mongodb-memory-server-core"; 4 5 let mongoServer: MongoMemoryServer | null = null; ··· 27 async fn() { 28 const uri = await setupTestServer(); 29 const connection = await connect(uri, "test_db"); 30 - 31 assert(connection); 32 assert(connection.client); 33 assert(connection.db); ··· 47 maxIdleTimeMS: 30000, 48 connectTimeoutMS: 5000, 49 }; 50 - 51 const connection = await connect(uri, "test_db", options); 52 - 53 assert(connection); 54 assert(connection.client); 55 assert(connection.db); 56 - 57 // Verify connection is working 58 const adminDb = connection.db.admin(); 59 const serverStatus = await adminDb.serverStatus(); ··· 67 name: "Connection: Singleton - should reuse existing connection", 68 async fn() { 69 const uri = await setupTestServer(); 70 - 71 const connection1 = await connect(uri, "test_db"); 72 const connection2 = await connect(uri, "test_db"); 73 - 74 // Should return the same connection instance 75 assertEquals(connection1, connection2); 76 assertEquals(connection1.client, connection2.client); ··· 84 name: "Connection: Disconnect - should disconnect and allow reconnection", 85 async fn() { 86 const uri = await setupTestServer(); 87 - 88 const connection1 = await connect(uri, "test_db"); 89 assert(connection1); 90 - 91 await disconnect(); 92 - 93 // Should be able to reconnect 94 const connection2 = await connect(uri, "test_db"); 95 assert(connection2); 96 - 97 // Should be a new connection instance 98 assert(connection1 !== connection2); 99 }, ··· 108 const options: ConnectOptions = { 109 maxPoolSize: 5, 110 }; 111 - 112 const connection = await connect(uri, "test_db", options); 113 - 114 // Verify connection works with custom pool size 115 const collections = await connection.db.listCollections().toArray(); 116 assert(Array.isArray(collections)); ··· 120 }); 121 122 Deno.test({ 123 - name: "Connection: Multiple Databases - should handle different database names", 124 async fn() { 125 const uri = await setupTestServer(); 126 - 127 // Connect to first database 128 const connection1 = await connect(uri, "db1"); 129 assertEquals(connection1.db.databaseName, "db1"); 130 - 131 // Disconnect first 132 await disconnect(); 133 - 134 // Connect to second database 135 const connection2 = await connect(uri, "db2"); 136 assertEquals(connection2.db.databaseName, "db2"); ··· 143 name: "Health Check: should return unhealthy when not connected", 144 async fn() { 145 const result = await healthCheck(); 146 - 147 assertEquals(result.healthy, false); 148 assertEquals(result.connected, false); 149 assertExists(result.error); ··· 160 async fn() { 161 const uri = await setupTestServer(); 162 await connect(uri, "test_db"); 163 - 164 const result = await healthCheck(); 165 - 166 assertEquals(result.healthy, true); 167 assertEquals(result.connected, true); 168 assertExists(result.responseTimeMs); ··· 179 async fn() { 180 const uri = await setupTestServer(); 181 await connect(uri, "test_db"); 182 - 183 const result = await healthCheck(); 184 - 185 assertEquals(result.healthy, true); 186 assertExists(result.responseTimeMs); 187 // Response time should be reasonable (less than 1 second for in-memory MongoDB) ··· 196 async fn() { 197 const uri = await setupTestServer(); 198 await connect(uri, "test_db"); 199 - 200 // Run health check multiple times 201 const results = await Promise.all([ 202 healthCheck(), 203 healthCheck(), 204 healthCheck(), 205 ]); 206 - 207 // All should be healthy 208 for (const result of results) { 209 assertEquals(result.healthy, true); ··· 220 async fn() { 221 const uri = await setupTestServer(); 222 await connect(uri, "test_db"); 223 - 224 // First check should be healthy 225 let result = await healthCheck(); 226 assertEquals(result.healthy, true); 227 - 228 // Disconnect 229 await disconnect(); 230 - 231 // Second check should be unhealthy 232 result = await healthCheck(); 233 assertEquals(result.healthy, false); ··· 247 serverSelectionTimeoutMS: 5000, 248 connectTimeoutMS: 5000, 249 }; 250 - 251 const connection = await connect(uri, "test_db", options); 252 - 253 assert(connection); 254 assert(connection.client); 255 assert(connection.db); 256 - 257 // Verify connection works with retry options 258 const collections = await connection.db.listCollections().toArray(); 259 assert(Array.isArray(collections)); ··· 270 // Pooling 271 maxPoolSize: 10, 272 minPoolSize: 2, 273 - 274 // Retry logic 275 retryReads: true, 276 retryWrites: true, 277 - 278 // Timeouts 279 connectTimeoutMS: 10000, 280 socketTimeoutMS: 45000, 281 serverSelectionTimeoutMS: 10000, 282 - 283 // Resilience 284 maxIdleTimeMS: 30000, 285 heartbeatFrequencyMS: 10000, 286 }; 287 - 288 const connection = await connect(uri, "test_db", options); 289 - 290 assert(connection); 291 - 292 // Verify connection is working 293 const adminDb = connection.db.admin(); 294 const serverStatus = await adminDb.serverStatus(); ··· 297 sanitizeResources: false, 298 sanitizeOps: false, 299 }); 300 -
··· 1 import { assert, assertEquals, assertExists } from "@std/assert"; 2 + import { 3 + connect, 4 + type ConnectOptions, 5 + disconnect, 6 + healthCheck, 7 + } from "../mod.ts"; 8 import { MongoMemoryServer } from "mongodb-memory-server-core"; 9 10 let mongoServer: MongoMemoryServer | null = null; ··· 32 async fn() { 33 const uri = await setupTestServer(); 34 const connection = await connect(uri, "test_db"); 35 + 36 assert(connection); 37 assert(connection.client); 38 assert(connection.db); ··· 52 maxIdleTimeMS: 30000, 53 connectTimeoutMS: 5000, 54 }; 55 + 56 const connection = await connect(uri, "test_db", options); 57 + 58 assert(connection); 59 assert(connection.client); 60 assert(connection.db); 61 + 62 // Verify connection is working 63 const adminDb = connection.db.admin(); 64 const serverStatus = await adminDb.serverStatus(); ··· 72 name: "Connection: Singleton - should reuse existing connection", 73 async fn() { 74 const uri = await setupTestServer(); 75 + 76 const connection1 = await connect(uri, "test_db"); 77 const connection2 = await connect(uri, "test_db"); 78 + 79 // Should return the same connection instance 80 assertEquals(connection1, connection2); 81 assertEquals(connection1.client, connection2.client); ··· 89 name: "Connection: Disconnect - should disconnect and allow reconnection", 90 async fn() { 91 const uri = await setupTestServer(); 92 + 93 const connection1 = await connect(uri, "test_db"); 94 assert(connection1); 95 + 96 await disconnect(); 97 + 98 // Should be able to reconnect 99 const connection2 = await connect(uri, "test_db"); 100 assert(connection2); 101 + 102 // Should be a new connection instance 103 assert(connection1 !== connection2); 104 }, ··· 113 const options: ConnectOptions = { 114 maxPoolSize: 5, 115 }; 116 + 117 const connection = await connect(uri, "test_db", options); 118 + 119 // Verify connection works with custom pool size 120 const collections = await connection.db.listCollections().toArray(); 121 assert(Array.isArray(collections)); ··· 125 }); 126 127 Deno.test({ 128 + name: 129 + "Connection: Multiple Databases - should handle different database names", 130 async fn() { 131 const uri = await setupTestServer(); 132 + 133 // Connect to first database 134 const connection1 = await connect(uri, "db1"); 135 assertEquals(connection1.db.databaseName, "db1"); 136 + 137 // Disconnect first 138 await disconnect(); 139 + 140 // Connect to second database 141 const connection2 = await connect(uri, "db2"); 142 assertEquals(connection2.db.databaseName, "db2"); ··· 149 name: "Health Check: should return unhealthy when not connected", 150 async fn() { 151 const result = await healthCheck(); 152 + 153 assertEquals(result.healthy, false); 154 assertEquals(result.connected, false); 155 assertExists(result.error); ··· 166 async fn() { 167 const uri = await setupTestServer(); 168 await connect(uri, "test_db"); 169 + 170 const result = await healthCheck(); 171 + 172 assertEquals(result.healthy, true); 173 assertEquals(result.connected, true); 174 assertExists(result.responseTimeMs); ··· 185 async fn() { 186 const uri = await setupTestServer(); 187 await connect(uri, "test_db"); 188 + 189 const result = await healthCheck(); 190 + 191 assertEquals(result.healthy, true); 192 assertExists(result.responseTimeMs); 193 // Response time should be reasonable (less than 1 second for in-memory MongoDB) ··· 202 async fn() { 203 const uri = await setupTestServer(); 204 await connect(uri, "test_db"); 205 + 206 // Run health check multiple times 207 const results = await Promise.all([ 208 healthCheck(), 209 healthCheck(), 210 healthCheck(), 211 ]); 212 + 213 // All should be healthy 214 for (const result of results) { 215 assertEquals(result.healthy, true); ··· 226 async fn() { 227 const uri = await setupTestServer(); 228 await connect(uri, "test_db"); 229 + 230 // First check should be healthy 231 let result = await healthCheck(); 232 assertEquals(result.healthy, true); 233 + 234 // Disconnect 235 await disconnect(); 236 + 237 // Second check should be unhealthy 238 result = await healthCheck(); 239 assertEquals(result.healthy, false); ··· 253 serverSelectionTimeoutMS: 5000, 254 connectTimeoutMS: 5000, 255 }; 256 + 257 const connection = await connect(uri, "test_db", options); 258 + 259 assert(connection); 260 assert(connection.client); 261 assert(connection.db); 262 + 263 // Verify connection works with retry options 264 const collections = await connection.db.listCollections().toArray(); 265 assert(Array.isArray(collections)); ··· 276 // Pooling 277 maxPoolSize: 10, 278 minPoolSize: 2, 279 + 280 // Retry logic 281 retryReads: true, 282 retryWrites: true, 283 + 284 // Timeouts 285 connectTimeoutMS: 10000, 286 socketTimeoutMS: 45000, 287 serverSelectionTimeoutMS: 10000, 288 + 289 // Resilience 290 maxIdleTimeMS: 30000, 291 heartbeatFrequencyMS: 10000, 292 }; 293 + 294 const connection = await connect(uri, "test_db", options); 295 + 296 assert(connection); 297 + 298 // Verify connection is working 299 const adminDb = connection.db.admin(); 300 const serverStatus = await adminDb.serverStatus(); ··· 303 sanitizeResources: false, 304 sanitizeOps: false, 305 });
+1 -8
tests/crud_test.ts
··· 14 15 Deno.test.beforeAll(async () => { 16 await setupTestDb(); 17 - UserModel = createUserModel(); 18 }); 19 20 Deno.test.beforeEach(async () => { ··· 28 Deno.test({ 29 name: "CRUD: Insert - should insert a new user successfully", 30 async fn() { 31 - 32 const newUser: UserInsert = { 33 name: "Test User", 34 email: "test@example.com", ··· 47 Deno.test({ 48 name: "CRUD: Find - should find the inserted user", 49 async fn() { 50 - 51 // First insert a user for this test 52 const newUser: UserInsert = { 53 name: "Find Test User", ··· 73 Deno.test({ 74 name: "CRUD: Update - should update user data", 75 async fn() { 76 - 77 // Insert a user for this test 78 const newUser: UserInsert = { 79 name: "Update Test User", ··· 106 Deno.test({ 107 name: "CRUD: Delete - should delete user successfully", 108 async fn() { 109 - 110 // Insert a user for this test 111 const newUser: UserInsert = { 112 name: "Delete Test User", ··· 137 Deno.test({ 138 name: "CRUD: Find Multiple - should find multiple users", 139 async fn() { 140 - 141 // Insert multiple users 142 const users: UserInsert[] = [ 143 { name: "User 1", email: "user1@example.com", age: 20 }, ··· 157 sanitizeResources: false, 158 sanitizeOps: false, 159 }); 160 - 161 -
··· 14 15 Deno.test.beforeAll(async () => { 16 await setupTestDb(); 17 + UserModel = createUserModel("users_crud"); 18 }); 19 20 Deno.test.beforeEach(async () => { ··· 28 Deno.test({ 29 name: "CRUD: Insert - should insert a new user successfully", 30 async fn() { 31 const newUser: UserInsert = { 32 name: "Test User", 33 email: "test@example.com", ··· 46 Deno.test({ 47 name: "CRUD: Find - should find the inserted user", 48 async fn() { 49 // First insert a user for this test 50 const newUser: UserInsert = { 51 name: "Find Test User", ··· 71 Deno.test({ 72 name: "CRUD: Update - should update user data", 73 async fn() { 74 // Insert a user for this test 75 const newUser: UserInsert = { 76 name: "Update Test User", ··· 103 Deno.test({ 104 name: "CRUD: Delete - should delete user successfully", 105 async fn() { 106 // Insert a user for this test 107 const newUser: UserInsert = { 108 name: "Delete Test User", ··· 133 Deno.test({ 134 name: "CRUD: Find Multiple - should find multiple users", 135 async fn() { 136 // Insert multiple users 137 const users: UserInsert[] = [ 138 { name: "User 1", email: "user1@example.com", age: 20 }, ··· 152 sanitizeResources: false, 153 sanitizeOps: false, 154 });
+33 -31
tests/defaults_test.ts
··· 1 import { assertEquals, assertExists } from "@std/assert"; 2 import { z } from "@zod/zod"; 3 - import { connect, disconnect, Model } from "../mod.ts"; 4 import { applyDefaultsForUpsert } from "../model/validation.ts"; 5 - import { MongoMemoryServer } from "mongodb-memory-server-core"; 6 7 /** 8 * Test suite for default value handling in different operation types 9 - * 10 * This tests the three main cases: 11 * 1. Plain inserts - defaults applied directly 12 * 2. Updates without upsert - defaults NOT applied ··· 26 }); 27 28 let ProductModel: Model<typeof productSchema>; 29 - let mongoServer: MongoMemoryServer; 30 31 Deno.test.beforeAll(async () => { 32 - mongoServer = await MongoMemoryServer.create(); 33 - const uri = mongoServer.getUri(); 34 - await connect(uri, "test_defaults_db"); 35 ProductModel = new Model("test_products_defaults", productSchema); 36 }); 37 ··· 41 42 Deno.test.afterAll(async () => { 43 await ProductModel.delete({}); 44 - await disconnect(); 45 - await mongoServer.stop(); 46 }); 47 48 Deno.test({ ··· 60 // Verify defaults were applied 61 const product = await ProductModel.findById(result.insertedId); 62 assertExists(product); 63 - 64 assertEquals(product.name, "Widget"); 65 assertEquals(product.price, 29.99); 66 assertEquals(product.category, "general"); // default ··· 84 createdAt: new Date("2023-01-01"), 85 tags: ["test"], 86 }); 87 - 88 assertExists(insertResult.insertedId); 89 90 // Now update it - defaults should NOT be applied 91 await ProductModel.updateOne( 92 { _id: insertResult.insertedId }, 93 - { price: 24.99 } 94 // No upsert flag 95 ); 96 97 const updated = await ProductModel.findById(insertResult.insertedId); 98 assertExists(updated); 99 - 100 assertEquals(updated.price, 24.99); // updated 101 assertEquals(updated.category, "electronics"); // unchanged 102 assertEquals(updated.inStock, false); // unchanged ··· 107 }); 108 109 Deno.test({ 110 - name: "Defaults: Case 3 - Upsert that creates applies defaults via $setOnInsert", 111 async fn() { 112 // Upsert with a query that won't match - will create new document 113 const result = await ProductModel.updateOne( 114 { name: "NonExistent" }, 115 { price: 39.99 }, 116 - { upsert: true } 117 ); 118 119 assertEquals(result.upsertedCount, 1); ··· 122 // Verify the created document has defaults applied 123 const product = await ProductModel.findOne({ name: "NonExistent" }); 124 assertExists(product); 125 - 126 assertEquals(product.price, 39.99); // from $set 127 assertEquals(product.name, "NonExistent"); // from query 128 assertEquals(product.category, "general"); // default via $setOnInsert ··· 153 const result = await ProductModel.updateOne( 154 { name: "ExistingProduct" }, 155 { price: 44.99 }, 156 - { upsert: true } 157 ); 158 159 assertEquals(result.matchedCount, 1); ··· 163 // Verify defaults were NOT applied (existing values preserved) 164 const product = await ProductModel.findOne({ name: "ExistingProduct" }); 165 assertExists(product); 166 - 167 assertEquals(product.price, 44.99); // updated via $set 168 assertEquals(product.category, "premium"); // preserved (not overwritten with default) 169 assertEquals(product.inStock, false); // preserved ··· 195 name: "Replaced", 196 price: 15.0, 197 // category, inStock, createdAt, tags not provided - defaults should apply 198 - } 199 ); 200 201 const product = await ProductModel.findById(insertResult.insertedId); 202 assertExists(product); 203 - 204 assertEquals(product.name, "Replaced"); 205 assertEquals(product.price, 15.0); 206 assertEquals(product.category, "general"); // default applied ··· 223 price: 99.99, 224 // Missing optional fields - defaults should apply 225 }, 226 - { upsert: true } 227 ); 228 229 assertEquals(result.upsertedCount, 1); ··· 231 232 const product = await ProductModel.findOne({ name: "NewViaReplace" }); 233 assertExists(product); 234 - 235 assertEquals(product.name, "NewViaReplace"); 236 assertEquals(product.price, 99.99); 237 assertEquals(product.category, "general"); // default ··· 254 category: "custom", // Explicitly setting a field that has a default 255 // inStock not set - should get default 256 }, 257 - { upsert: true } 258 ); 259 260 assertEquals(result.upsertedCount, 1); 261 262 const product = await ProductModel.findOne({ name: "CustomDefaults" }); 263 assertExists(product); 264 - 265 assertEquals(product.name, "CustomDefaults"); // from query 266 assertEquals(product.price, 25.0); // from $set 267 assertEquals(product.category, "custom"); // from $set (NOT default) ··· 292 assertExists(product.createdAt); 293 assertEquals(product.inStock, true); 294 assertEquals(product.tags, []); 295 - 296 if (product.name === "Bulk2") { 297 assertEquals(product.category, "special"); 298 } else { ··· 305 }); 306 307 Deno.test({ 308 - name: "Defaults: applyDefaultsForUpsert preserves existing $setOnInsert values", 309 fn() { 310 const schema = z.object({ 311 name: z.string(), ··· 328 }); 329 330 Deno.test({ 331 - name: "Defaults: applyDefaultsForUpsert keeps query equality fields untouched", 332 fn() { 333 const schema = z.object({ 334 status: z.string().default("pending"), ··· 349 }); 350 351 Deno.test({ 352 - name: "Defaults: findOneAndUpdate with upsert preserves query equality fields", 353 async fn() { 354 await ProductModel.findOneAndUpdate( 355 { name: "FindOneUpsert", category: "special" }, 356 { price: 12.5 }, 357 - { upsert: true } 358 ); 359 360 const product = await ProductModel.findOne({ name: "FindOneUpsert" }); ··· 379 name: "FindOneReplaceUpsert", 380 price: 77.0, 381 }, 382 - { upsert: true } 383 ); 384 385 assertExists(result.lastErrorObject?.upserted); 386 387 - const product = await ProductModel.findOne({ name: "FindOneReplaceUpsert" }); 388 assertExists(product); 389 390 assertEquals(product.name, "FindOneReplaceUpsert");
··· 1 import { assertEquals, assertExists } from "@std/assert"; 2 import { z } from "@zod/zod"; 3 + import { Model } from "../mod.ts"; 4 import { applyDefaultsForUpsert } from "../model/validation.ts"; 5 + import { setupTestDb, teardownTestDb } from "./utils.ts"; 6 7 /** 8 * Test suite for default value handling in different operation types 9 + * 10 * This tests the three main cases: 11 * 1. Plain inserts - defaults applied directly 12 * 2. Updates without upsert - defaults NOT applied ··· 26 }); 27 28 let ProductModel: Model<typeof productSchema>; 29 30 Deno.test.beforeAll(async () => { 31 + await setupTestDb(); 32 ProductModel = new Model("test_products_defaults", productSchema); 33 }); 34 ··· 38 39 Deno.test.afterAll(async () => { 40 await ProductModel.delete({}); 41 + await teardownTestDb(); 42 }); 43 44 Deno.test({ ··· 56 // Verify defaults were applied 57 const product = await ProductModel.findById(result.insertedId); 58 assertExists(product); 59 + 60 assertEquals(product.name, "Widget"); 61 assertEquals(product.price, 29.99); 62 assertEquals(product.category, "general"); // default ··· 80 createdAt: new Date("2023-01-01"), 81 tags: ["test"], 82 }); 83 + 84 assertExists(insertResult.insertedId); 85 86 // Now update it - defaults should NOT be applied 87 await ProductModel.updateOne( 88 { _id: insertResult.insertedId }, 89 + { price: 24.99 }, 90 // No upsert flag 91 ); 92 93 const updated = await ProductModel.findById(insertResult.insertedId); 94 assertExists(updated); 95 + 96 assertEquals(updated.price, 24.99); // updated 97 assertEquals(updated.category, "electronics"); // unchanged 98 assertEquals(updated.inStock, false); // unchanged ··· 103 }); 104 105 Deno.test({ 106 + name: 107 + "Defaults: Case 3 - Upsert that creates applies defaults via $setOnInsert", 108 async fn() { 109 // Upsert with a query that won't match - will create new document 110 const result = await ProductModel.updateOne( 111 { name: "NonExistent" }, 112 { price: 39.99 }, 113 + { upsert: true }, 114 ); 115 116 assertEquals(result.upsertedCount, 1); ··· 119 // Verify the created document has defaults applied 120 const product = await ProductModel.findOne({ name: "NonExistent" }); 121 assertExists(product); 122 + 123 assertEquals(product.price, 39.99); // from $set 124 assertEquals(product.name, "NonExistent"); // from query 125 assertEquals(product.category, "general"); // default via $setOnInsert ··· 150 const result = await ProductModel.updateOne( 151 { name: "ExistingProduct" }, 152 { price: 44.99 }, 153 + { upsert: true }, 154 ); 155 156 assertEquals(result.matchedCount, 1); ··· 160 // Verify defaults were NOT applied (existing values preserved) 161 const product = await ProductModel.findOne({ name: "ExistingProduct" }); 162 assertExists(product); 163 + 164 assertEquals(product.price, 44.99); // updated via $set 165 assertEquals(product.category, "premium"); // preserved (not overwritten with default) 166 assertEquals(product.inStock, false); // preserved ··· 192 name: "Replaced", 193 price: 15.0, 194 // category, inStock, createdAt, tags not provided - defaults should apply 195 + }, 196 ); 197 198 const product = await ProductModel.findById(insertResult.insertedId); 199 assertExists(product); 200 + 201 assertEquals(product.name, "Replaced"); 202 assertEquals(product.price, 15.0); 203 assertEquals(product.category, "general"); // default applied ··· 220 price: 99.99, 221 // Missing optional fields - defaults should apply 222 }, 223 + { upsert: true }, 224 ); 225 226 assertEquals(result.upsertedCount, 1); ··· 228 229 const product = await ProductModel.findOne({ name: "NewViaReplace" }); 230 assertExists(product); 231 + 232 assertEquals(product.name, "NewViaReplace"); 233 assertEquals(product.price, 99.99); 234 assertEquals(product.category, "general"); // default ··· 251 category: "custom", // Explicitly setting a field that has a default 252 // inStock not set - should get default 253 }, 254 + { upsert: true }, 255 ); 256 257 assertEquals(result.upsertedCount, 1); 258 259 const product = await ProductModel.findOne({ name: "CustomDefaults" }); 260 assertExists(product); 261 + 262 assertEquals(product.name, "CustomDefaults"); // from query 263 assertEquals(product.price, 25.0); // from $set 264 assertEquals(product.category, "custom"); // from $set (NOT default) ··· 289 assertExists(product.createdAt); 290 assertEquals(product.inStock, true); 291 assertEquals(product.tags, []); 292 + 293 if (product.name === "Bulk2") { 294 assertEquals(product.category, "special"); 295 } else { ··· 302 }); 303 304 Deno.test({ 305 + name: 306 + "Defaults: applyDefaultsForUpsert preserves existing $setOnInsert values", 307 fn() { 308 const schema = z.object({ 309 name: z.string(), ··· 326 }); 327 328 Deno.test({ 329 + name: 330 + "Defaults: applyDefaultsForUpsert keeps query equality fields untouched", 331 fn() { 332 const schema = z.object({ 333 status: z.string().default("pending"), ··· 348 }); 349 350 Deno.test({ 351 + name: 352 + "Defaults: findOneAndUpdate with upsert preserves query equality fields", 353 async fn() { 354 await ProductModel.findOneAndUpdate( 355 { name: "FindOneUpsert", category: "special" }, 356 { price: 12.5 }, 357 + { upsert: true }, 358 ); 359 360 const product = await ProductModel.findOne({ name: "FindOneUpsert" }); ··· 379 name: "FindOneReplaceUpsert", 380 price: 77.0, 381 }, 382 + { upsert: true }, 383 ); 384 385 assertExists(result.lastErrorObject?.upserted); 386 387 + const product = await ProductModel.findOne({ 388 + name: "FindOneReplaceUpsert", 389 + }); 390 assertExists(product); 391 392 assertEquals(product.name, "FindOneReplaceUpsert");
+53 -38
tests/errors_test.ts
··· 1 import { assert, assertEquals, assertExists, assertRejects } from "@std/assert"; 2 import { 3 connect, 4 disconnect, 5 Model, 6 ValidationError, 7 - ConnectionError, 8 } from "../mod.ts"; 9 import { z } from "@zod/zod"; 10 import { MongoMemoryServer } from "mongodb-memory-server-core"; ··· 41 async fn() { 42 const uri = await setupTestServer(); 43 await connect(uri, "test_db"); 44 - 45 const UserModel = new Model("users", userSchema); 46 - 47 await assertRejects( 48 async () => { 49 await UserModel.insertOne({ name: "", email: "invalid" }); 50 }, 51 ValidationError, 52 - "Validation failed on insert" 53 ); 54 }, 55 sanitizeResources: false, ··· 61 async fn() { 62 const uri = await setupTestServer(); 63 await connect(uri, "test_db"); 64 - 65 const UserModel = new Model("users", userSchema); 66 - 67 try { 68 await UserModel.insertOne({ name: "", email: "invalid" }); 69 throw new Error("Should have thrown ValidationError"); ··· 72 assertEquals(error.operation, "insert"); 73 assertExists(error.issues); 74 assert(error.issues.length > 0); 75 - 76 // Check field errors 77 const fieldErrors = error.getFieldErrors(); 78 assertExists(fieldErrors.name); ··· 88 async fn() { 89 const uri = await setupTestServer(); 90 await connect(uri, "test_db"); 91 - 92 const UserModel = new Model("users", userSchema); 93 - 94 await assertRejects( 95 async () => { 96 await UserModel.updateOne({ name: "test" }, { email: "invalid-email" }); 97 }, 98 ValidationError, 99 - "Validation failed on update" 100 ); 101 }, 102 sanitizeResources: false, ··· 108 async fn() { 109 const uri = await setupTestServer(); 110 await connect(uri, "test_db"); 111 - 112 const UserModel = new Model("users", userSchema); 113 - 114 // First insert a valid document 115 await UserModel.insertOne({ name: "Test", email: "test@example.com" }); 116 - 117 await assertRejects( 118 async () => { 119 - await UserModel.replaceOne({ name: "Test" }, { name: "", email: "invalid" }); 120 }, 121 ValidationError, 122 - "Validation failed on replace" 123 ); 124 }, 125 sanitizeResources: false, ··· 131 async fn() { 132 const uri = await setupTestServer(); 133 await connect(uri, "test_db"); 134 - 135 const UserModel = new Model("users", userSchema); 136 - 137 try { 138 await UserModel.updateOne({ name: "test" }, { age: -5 }); 139 throw new Error("Should have thrown ValidationError"); 140 } catch (error) { 141 assert(error instanceof ValidationError); 142 assertEquals(error.operation, "update"); 143 - 144 const fieldErrors = error.getFieldErrors(); 145 assertExists(fieldErrors.age); 146 } ··· 154 async fn() { 155 await assertRejects( 156 async () => { 157 - await connect("mongodb://invalid-host-that-does-not-exist:27017", "test_db", { 158 - serverSelectionTimeoutMS: 1000, // 1 second timeout 159 - connectTimeoutMS: 1000, 160 - }); 161 }, 162 ConnectionError, 163 - "Failed to connect to MongoDB" 164 ); 165 }, 166 sanitizeResources: false, ··· 171 name: "Errors: ConnectionError - should include URI in error", 172 async fn() { 173 try { 174 - await connect("mongodb://invalid-host-that-does-not-exist:27017", "test_db", { 175 - serverSelectionTimeoutMS: 1000, // 1 second timeout 176 - connectTimeoutMS: 1000, 177 - }); 178 throw new Error("Should have thrown ConnectionError"); 179 } catch (error) { 180 assert(error instanceof ConnectionError); 181 - assertEquals(error.uri, "mongodb://invalid-host-that-does-not-exist:27017"); 182 } 183 }, 184 sanitizeResources: false, ··· 186 }); 187 188 Deno.test({ 189 - name: "Errors: ConnectionError - should throw when getDb called without connection", 190 async fn() { 191 // Make sure not connected 192 await disconnect(); 193 - 194 const { getDb } = await import("../client/connection.ts"); 195 - 196 try { 197 getDb(); 198 throw new Error("Should have thrown ConnectionError"); ··· 210 async fn() { 211 const uri = await setupTestServer(); 212 await connect(uri, "test_db"); 213 - 214 const UserModel = new Model("users", userSchema); 215 - 216 try { 217 await UserModel.insertOne({ 218 name: "", ··· 222 throw new Error("Should have thrown ValidationError"); 223 } catch (error) { 224 assert(error instanceof ValidationError); 225 - 226 const fieldErrors = error.getFieldErrors(); 227 - 228 // Each field should have its own errors 229 assert(Array.isArray(fieldErrors.name)); 230 assert(Array.isArray(fieldErrors.email)); 231 assert(Array.isArray(fieldErrors.age)); 232 - 233 // Verify error messages are present 234 assert(fieldErrors.name.length > 0); 235 assert(fieldErrors.email.length > 0); ··· 245 async fn() { 246 const uri = await setupTestServer(); 247 await connect(uri, "test_db"); 248 - 249 const UserModel = new Model("users", userSchema); 250 - 251 try { 252 await UserModel.insertOne({ name: "", email: "invalid" }); 253 } catch (error) {
··· 1 import { assert, assertEquals, assertExists, assertRejects } from "@std/assert"; 2 import { 3 connect, 4 + ConnectionError, 5 disconnect, 6 Model, 7 ValidationError, 8 } from "../mod.ts"; 9 import { z } from "@zod/zod"; 10 import { MongoMemoryServer } from "mongodb-memory-server-core"; ··· 41 async fn() { 42 const uri = await setupTestServer(); 43 await connect(uri, "test_db"); 44 + 45 const UserModel = new Model("users", userSchema); 46 + 47 await assertRejects( 48 async () => { 49 await UserModel.insertOne({ name: "", email: "invalid" }); 50 }, 51 ValidationError, 52 + "Validation failed on insert", 53 ); 54 }, 55 sanitizeResources: false, ··· 61 async fn() { 62 const uri = await setupTestServer(); 63 await connect(uri, "test_db"); 64 + 65 const UserModel = new Model("users", userSchema); 66 + 67 try { 68 await UserModel.insertOne({ name: "", email: "invalid" }); 69 throw new Error("Should have thrown ValidationError"); ··· 72 assertEquals(error.operation, "insert"); 73 assertExists(error.issues); 74 assert(error.issues.length > 0); 75 + 76 // Check field errors 77 const fieldErrors = error.getFieldErrors(); 78 assertExists(fieldErrors.name); ··· 88 async fn() { 89 const uri = await setupTestServer(); 90 await connect(uri, "test_db"); 91 + 92 const UserModel = new Model("users", userSchema); 93 + 94 await assertRejects( 95 async () => { 96 await UserModel.updateOne({ name: "test" }, { email: "invalid-email" }); 97 }, 98 ValidationError, 99 + "Validation failed on update", 100 ); 101 }, 102 sanitizeResources: false, ··· 108 async fn() { 109 const uri = await setupTestServer(); 110 await connect(uri, "test_db"); 111 + 112 const UserModel = new Model("users", userSchema); 113 + 114 // First insert a valid document 115 await UserModel.insertOne({ name: "Test", email: "test@example.com" }); 116 + 117 await assertRejects( 118 async () => { 119 + await UserModel.replaceOne({ name: "Test" }, { 120 + name: "", 121 + email: "invalid", 122 + }); 123 }, 124 ValidationError, 125 + "Validation failed on replace", 126 ); 127 }, 128 sanitizeResources: false, ··· 134 async fn() { 135 const uri = await setupTestServer(); 136 await connect(uri, "test_db"); 137 + 138 const UserModel = new Model("users", userSchema); 139 + 140 try { 141 await UserModel.updateOne({ name: "test" }, { age: -5 }); 142 throw new Error("Should have thrown ValidationError"); 143 } catch (error) { 144 assert(error instanceof ValidationError); 145 assertEquals(error.operation, "update"); 146 + 147 const fieldErrors = error.getFieldErrors(); 148 assertExists(fieldErrors.age); 149 } ··· 157 async fn() { 158 await assertRejects( 159 async () => { 160 + await connect( 161 + "mongodb://invalid-host-that-does-not-exist:27017", 162 + "test_db", 163 + { 164 + serverSelectionTimeoutMS: 1000, // 1 second timeout 165 + connectTimeoutMS: 1000, 166 + }, 167 + ); 168 }, 169 ConnectionError, 170 + "Failed to connect to MongoDB", 171 ); 172 }, 173 sanitizeResources: false, ··· 178 name: "Errors: ConnectionError - should include URI in error", 179 async fn() { 180 try { 181 + await connect( 182 + "mongodb://invalid-host-that-does-not-exist:27017", 183 + "test_db", 184 + { 185 + serverSelectionTimeoutMS: 1000, // 1 second timeout 186 + connectTimeoutMS: 1000, 187 + }, 188 + ); 189 throw new Error("Should have thrown ConnectionError"); 190 } catch (error) { 191 assert(error instanceof ConnectionError); 192 + assertEquals( 193 + error.uri, 194 + "mongodb://invalid-host-that-does-not-exist:27017", 195 + ); 196 } 197 }, 198 sanitizeResources: false, ··· 200 }); 201 202 Deno.test({ 203 + name: 204 + "Errors: ConnectionError - should throw when getDb called without connection", 205 async fn() { 206 // Make sure not connected 207 await disconnect(); 208 + 209 const { getDb } = await import("../client/connection.ts"); 210 + 211 try { 212 getDb(); 213 throw new Error("Should have thrown ConnectionError"); ··· 225 async fn() { 226 const uri = await setupTestServer(); 227 await connect(uri, "test_db"); 228 + 229 const UserModel = new Model("users", userSchema); 230 + 231 try { 232 await UserModel.insertOne({ 233 name: "", ··· 237 throw new Error("Should have thrown ValidationError"); 238 } catch (error) { 239 assert(error instanceof ValidationError); 240 + 241 const fieldErrors = error.getFieldErrors(); 242 + 243 // Each field should have its own errors 244 assert(Array.isArray(fieldErrors.name)); 245 assert(Array.isArray(fieldErrors.email)); 246 assert(Array.isArray(fieldErrors.age)); 247 + 248 // Verify error messages are present 249 assert(fieldErrors.name.length > 0); 250 assert(fieldErrors.email.length > 0); ··· 260 async fn() { 261 const uri = await setupTestServer(); 262 await connect(uri, "test_db"); 263 + 264 const UserModel = new Model("users", userSchema); 265 + 266 try { 267 await UserModel.insertOne({ name: "", email: "invalid" }); 268 } catch (error) {
+1 -4
tests/features_test.ts
··· 14 15 Deno.test.beforeAll(async () => { 16 await setupTestDb(); 17 - UserModel = createUserModel(); 18 }); 19 20 Deno.test.beforeEach(async () => { ··· 28 Deno.test({ 29 name: "Features: Default Values - should handle default createdAt", 30 async fn() { 31 - 32 const newUser: UserInsert = { 33 name: "Default Test User", 34 email: "default@example.com", ··· 49 sanitizeResources: false, 50 sanitizeOps: false, 51 }); 52 - 53 -
··· 14 15 Deno.test.beforeAll(async () => { 16 await setupTestDb(); 17 + UserModel = createUserModel("users_features"); 18 }); 19 20 Deno.test.beforeEach(async () => { ··· 28 Deno.test({ 29 name: "Features: Default Values - should handle default createdAt", 30 async fn() { 31 const newUser: UserInsert = { 32 name: "Default Test User", 33 email: "default@example.com", ··· 48 sanitizeResources: false, 49 sanitizeOps: false, 50 });
+1 -2
tests/index_test.ts
··· 12 13 Deno.test.beforeAll(async () => { 14 await setupTestDb(); 15 - UserModel = createUserModel(); 16 }); 17 18 Deno.test.beforeEach(async () => { ··· 162 sanitizeResources: false, 163 sanitizeOps: false, 164 }); 165 -
··· 12 13 Deno.test.beforeAll(async () => { 14 await setupTestDb(); 15 + UserModel = createUserModel("users_index"); 16 }); 17 18 Deno.test.beforeEach(async () => { ··· 162 sanitizeResources: false, 163 sanitizeOps: false, 164 });
+71 -63
tests/transactions_test.ts
··· 2 import { 3 connect, 4 disconnect, 5 Model, 6 - withTransaction, 7 startSession, 8 - endSession, 9 } from "../mod.ts"; 10 import { z } from "@zod/zod"; 11 import { MongoMemoryReplSet } from "mongodb-memory-server-core"; ··· 15 async function setupTestReplSet() { 16 if (!replSet) { 17 replSet = await MongoMemoryReplSet.create({ 18 - replSet: { 19 - count: 3, 20 - storageEngine: 'wiredTiger' // Required for transactions 21 }, 22 }); 23 } ··· 63 async fn() { 64 const uri = await setupTestReplSet(); 65 await connect(uri, "test_db"); 66 - 67 const UserModel = new Model("users", userSchema); 68 const OrderModel = new Model("orders", orderSchema); 69 - 70 const result = await withTransaction(async (session) => { 71 const user = await UserModel.insertOne( 72 { name: "Alice", email: "alice@example.com", balance: 100 }, 73 - { session } 74 ); 75 - 76 const order = await OrderModel.insertOne( 77 { userId: user.insertedId.toString(), amount: 50 }, 78 - { session } 79 ); 80 - 81 return { userId: user.insertedId, orderId: order.insertedId }; 82 }); 83 - 84 assertExists(result.userId); 85 assertExists(result.orderId); 86 - 87 // Verify data was committed 88 const users = await UserModel.find({}); 89 const orders = await OrderModel.find({}); ··· 99 async fn() { 100 const uri = await setupTestReplSet(); 101 await connect(uri, "test_db"); 102 - 103 const UserModel = new Model("users", userSchema); 104 - 105 await assertRejects( 106 async () => { 107 await withTransaction(async (session) => { 108 await UserModel.insertOne( 109 { name: "Bob", email: "bob@example.com" }, 110 - { session } 111 ); 112 - 113 // This will fail and abort the transaction 114 throw new Error("Simulated error"); 115 }); 116 }, 117 Error, 118 - "Simulated error" 119 ); 120 - 121 // Verify no data was committed 122 const users = await UserModel.find({}); 123 assertEquals(users.length, 0); ··· 131 async fn() { 132 const uri = await setupTestReplSet(); 133 await connect(uri, "test_db"); 134 - 135 const UserModel = new Model("users", userSchema); 136 - 137 const result = await withTransaction(async (session) => { 138 const users = []; 139 - 140 for (let i = 0; i < 5; i++) { 141 const user = await UserModel.insertOne( 142 { name: `User${i}`, email: `user${i}@example.com` }, 143 - { session } 144 ); 145 users.push(user.insertedId); 146 } 147 - 148 return users; 149 }); 150 - 151 assertEquals(result.length, 5); 152 - 153 // Verify all users were created 154 const users = await UserModel.find({}); 155 assertEquals(users.length, 5); ··· 159 }); 160 161 Deno.test({ 162 - name: "Transactions: withTransaction - should support read and write operations", 163 async fn() { 164 const uri = await setupTestReplSet(); 165 await connect(uri, "test_db"); 166 - 167 const UserModel = new Model("users", userSchema); 168 - 169 // Insert initial user 170 const initialUser = await UserModel.insertOne({ 171 name: "Charlie", 172 email: "charlie@example.com", 173 balance: 100, 174 }); 175 - 176 const result = await withTransaction(async (session) => { 177 // Read 178 - const user = await UserModel.findById(initialUser.insertedId, { session }); 179 assertExists(user); 180 - 181 // Update 182 await UserModel.updateOne( 183 { _id: initialUser.insertedId }, 184 { balance: 150 }, 185 - { session } 186 ); 187 - 188 // Read again 189 - const updatedUser = await UserModel.findById(initialUser.insertedId, { session }); 190 - 191 return updatedUser?.balance; 192 }); 193 - 194 assertEquals(result, 150); 195 }, 196 sanitizeResources: false, ··· 202 async fn() { 203 const uri = await setupTestReplSet(); 204 await connect(uri, "test_db"); 205 - 206 const UserModel = new Model("users", userSchema); 207 - 208 await assertRejects( 209 async () => { 210 await withTransaction(async (session) => { 211 // Valid insert 212 await UserModel.insertOne( 213 { name: "Valid", email: "valid@example.com" }, 214 - { session } 215 ); 216 - 217 // Invalid insert (will throw ValidationError) 218 await UserModel.insertOne( 219 { name: "", email: "invalid" }, 220 - { session } 221 ); 222 }); 223 }, 224 - Error // ValidationError 225 ); 226 - 227 // Transaction should have been aborted, no data should exist 228 const users = await UserModel.find({}); 229 assertEquals(users.length, 0); ··· 233 }); 234 235 Deno.test({ 236 - name: "Transactions: Manual session - should work with manual session management", 237 async fn() { 238 const uri = await setupTestReplSet(); 239 await connect(uri, "test_db"); 240 - 241 const UserModel = new Model("users", userSchema); 242 - 243 const session = startSession(); 244 - 245 try { 246 await session.withTransaction(async () => { 247 await UserModel.insertOne( 248 { name: "Dave", email: "dave@example.com" }, 249 - { session } 250 ); 251 await UserModel.insertOne( 252 { name: "Eve", email: "eve@example.com" }, 253 - { session } 254 ); 255 }); 256 } finally { 257 await endSession(session); 258 } 259 - 260 // Verify both users were created 261 const users = await UserModel.find({}); 262 assertEquals(users.length, 2); ··· 270 async fn() { 271 const uri = await setupTestReplSet(); 272 await connect(uri, "test_db"); 273 - 274 const UserModel = new Model("users", userSchema); 275 - 276 // Insert initial users 277 await UserModel.insertMany([ 278 { name: "User1", email: "user1@example.com" }, 279 { name: "User2", email: "user2@example.com" }, 280 { name: "User3", email: "user3@example.com" }, 281 ]); 282 - 283 await withTransaction(async (session) => { 284 // Delete one user 285 await UserModel.deleteOne({ name: "User1" }, { session }); 286 - 287 // Delete multiple users 288 - await UserModel.delete({ name: { $in: ["User2", "User3"] } }, { session }); 289 }); 290 - 291 // Verify all were deleted 292 const users = await UserModel.find({}); 293 assertEquals(users.length, 0); ··· 301 async fn() { 302 const uri = await setupTestReplSet(); 303 await connect(uri, "test_db"); 304 - 305 const UserModel = new Model("users", userSchema); 306 - 307 const result = await withTransaction( 308 async (session) => { 309 await UserModel.insertOne( 310 { name: "Frank", email: "frank@example.com" }, 311 - { session } 312 ); 313 return "success"; 314 }, ··· 316 readPreference: "primary", 317 readConcern: { level: "snapshot" }, 318 writeConcern: { w: "majority" }, 319 - } 320 ); 321 - 322 assertEquals(result, "success"); 323 - 324 const users = await UserModel.find({}); 325 assertEquals(users.length, 1); 326 },
··· 2 import { 3 connect, 4 disconnect, 5 + endSession, 6 Model, 7 startSession, 8 + withTransaction, 9 } from "../mod.ts"; 10 import { z } from "@zod/zod"; 11 import { MongoMemoryReplSet } from "mongodb-memory-server-core"; ··· 15 async function setupTestReplSet() { 16 if (!replSet) { 17 replSet = await MongoMemoryReplSet.create({ 18 + replSet: { 19 + count: 1, 20 + storageEngine: "wiredTiger", // Required for transactions 21 }, 22 }); 23 } ··· 63 async fn() { 64 const uri = await setupTestReplSet(); 65 await connect(uri, "test_db"); 66 + 67 const UserModel = new Model("users", userSchema); 68 const OrderModel = new Model("orders", orderSchema); 69 + 70 const result = await withTransaction(async (session) => { 71 const user = await UserModel.insertOne( 72 { name: "Alice", email: "alice@example.com", balance: 100 }, 73 + { session }, 74 ); 75 + 76 const order = await OrderModel.insertOne( 77 { userId: user.insertedId.toString(), amount: 50 }, 78 + { session }, 79 ); 80 + 81 return { userId: user.insertedId, orderId: order.insertedId }; 82 }); 83 + 84 assertExists(result.userId); 85 assertExists(result.orderId); 86 + 87 // Verify data was committed 88 const users = await UserModel.find({}); 89 const orders = await OrderModel.find({}); ··· 99 async fn() { 100 const uri = await setupTestReplSet(); 101 await connect(uri, "test_db"); 102 + 103 const UserModel = new Model("users", userSchema); 104 + 105 await assertRejects( 106 async () => { 107 await withTransaction(async (session) => { 108 await UserModel.insertOne( 109 { name: "Bob", email: "bob@example.com" }, 110 + { session }, 111 ); 112 + 113 // This will fail and abort the transaction 114 throw new Error("Simulated error"); 115 }); 116 }, 117 Error, 118 + "Simulated error", 119 ); 120 + 121 // Verify no data was committed 122 const users = await UserModel.find({}); 123 assertEquals(users.length, 0); ··· 131 async fn() { 132 const uri = await setupTestReplSet(); 133 await connect(uri, "test_db"); 134 + 135 const UserModel = new Model("users", userSchema); 136 + 137 const result = await withTransaction(async (session) => { 138 const users = []; 139 + 140 for (let i = 0; i < 5; i++) { 141 const user = await UserModel.insertOne( 142 { name: `User${i}`, email: `user${i}@example.com` }, 143 + { session }, 144 ); 145 users.push(user.insertedId); 146 } 147 + 148 return users; 149 }); 150 + 151 assertEquals(result.length, 5); 152 + 153 // Verify all users were created 154 const users = await UserModel.find({}); 155 assertEquals(users.length, 5); ··· 159 }); 160 161 Deno.test({ 162 + name: 163 + "Transactions: withTransaction - should support read and write operations", 164 async fn() { 165 const uri = await setupTestReplSet(); 166 await connect(uri, "test_db"); 167 + 168 const UserModel = new Model("users", userSchema); 169 + 170 // Insert initial user 171 const initialUser = await UserModel.insertOne({ 172 name: "Charlie", 173 email: "charlie@example.com", 174 balance: 100, 175 }); 176 + 177 const result = await withTransaction(async (session) => { 178 // Read 179 + const user = await UserModel.findById(initialUser.insertedId, { 180 + session, 181 + }); 182 assertExists(user); 183 + 184 // Update 185 await UserModel.updateOne( 186 { _id: initialUser.insertedId }, 187 { balance: 150 }, 188 + { session }, 189 ); 190 + 191 // Read again 192 + const updatedUser = await UserModel.findById(initialUser.insertedId, { 193 + session, 194 + }); 195 + 196 return updatedUser?.balance; 197 }); 198 + 199 assertEquals(result, 150); 200 }, 201 sanitizeResources: false, ··· 207 async fn() { 208 const uri = await setupTestReplSet(); 209 await connect(uri, "test_db"); 210 + 211 const UserModel = new Model("users", userSchema); 212 + 213 await assertRejects( 214 async () => { 215 await withTransaction(async (session) => { 216 // Valid insert 217 await UserModel.insertOne( 218 { name: "Valid", email: "valid@example.com" }, 219 + { session }, 220 ); 221 + 222 // Invalid insert (will throw ValidationError) 223 await UserModel.insertOne( 224 { name: "", email: "invalid" }, 225 + { session }, 226 ); 227 }); 228 }, 229 + Error, // ValidationError 230 ); 231 + 232 // Transaction should have been aborted, no data should exist 233 const users = await UserModel.find({}); 234 assertEquals(users.length, 0); ··· 238 }); 239 240 Deno.test({ 241 + name: 242 + "Transactions: Manual session - should work with manual session management", 243 async fn() { 244 const uri = await setupTestReplSet(); 245 await connect(uri, "test_db"); 246 + 247 const UserModel = new Model("users", userSchema); 248 + 249 const session = startSession(); 250 + 251 try { 252 await session.withTransaction(async () => { 253 await UserModel.insertOne( 254 { name: "Dave", email: "dave@example.com" }, 255 + { session }, 256 ); 257 await UserModel.insertOne( 258 { name: "Eve", email: "eve@example.com" }, 259 + { session }, 260 ); 261 }); 262 } finally { 263 await endSession(session); 264 } 265 + 266 // Verify both users were created 267 const users = await UserModel.find({}); 268 assertEquals(users.length, 2); ··· 276 async fn() { 277 const uri = await setupTestReplSet(); 278 await connect(uri, "test_db"); 279 + 280 const UserModel = new Model("users", userSchema); 281 + 282 // Insert initial users 283 await UserModel.insertMany([ 284 { name: "User1", email: "user1@example.com" }, 285 { name: "User2", email: "user2@example.com" }, 286 { name: "User3", email: "user3@example.com" }, 287 ]); 288 + 289 await withTransaction(async (session) => { 290 // Delete one user 291 await UserModel.deleteOne({ name: "User1" }, { session }); 292 + 293 // Delete multiple users 294 + await UserModel.delete({ name: { $in: ["User2", "User3"] } }, { 295 + session, 296 + }); 297 }); 298 + 299 // Verify all were deleted 300 const users = await UserModel.find({}); 301 assertEquals(users.length, 0); ··· 309 async fn() { 310 const uri = await setupTestReplSet(); 311 await connect(uri, "test_db"); 312 + 313 const UserModel = new Model("users", userSchema); 314 + 315 const result = await withTransaction( 316 async (session) => { 317 await UserModel.insertOne( 318 { name: "Frank", email: "frank@example.com" }, 319 + { session }, 320 ); 321 return "success"; 322 }, ··· 324 readPreference: "primary", 325 readConcern: { level: "snapshot" }, 326 writeConcern: { w: "majority" }, 327 + }, 328 ); 329 + 330 assertEquals(result, "success"); 331 + 332 const users = await UserModel.find({}); 333 assertEquals(users.length, 1); 334 },
+35 -10
tests/utils.ts
··· 13 14 let mongoServer: MongoMemoryServer | null = null; 15 let isSetup = false; 16 17 - export async function setupTestDb() { 18 - if (!isSetup) { 19 - // Start MongoDB Memory Server 20 mongoServer = await MongoMemoryServer.create(); 21 const uri = mongoServer.getUri(); 22 - 23 - // Connect to the in-memory database 24 - await connect(uri, "test_db"); 25 isSetup = true; 26 } 27 } 28 29 export async function teardownTestDb() { 30 - if (isSetup) { 31 await disconnect(); 32 if (mongoServer) { 33 await mongoServer.stop(); 34 mongoServer = null; 35 } 36 isSetup = false; 37 } 38 } 39 40 - export function createUserModel(): Model<typeof userSchema> { 41 - return new Model("users", userSchema); 42 } 43 44 export async function cleanupCollection(model: Model<typeof userSchema>) { 45 await model.delete({}); 46 } 47 -
··· 13 14 let mongoServer: MongoMemoryServer | null = null; 15 let isSetup = false; 16 + let setupRefCount = 0; 17 + let activeDbName: string | null = null; 18 19 + export async function setupTestDb(dbName = "test_db") { 20 + setupRefCount++; 21 + 22 + // If we're already connected, just share the same database 23 + if (isSetup) { 24 + if (activeDbName !== dbName) { 25 + throw new Error( 26 + `Test DB already initialized for ${activeDbName}, requested ${dbName}`, 27 + ); 28 + } 29 + return; 30 + } 31 + 32 + try { 33 mongoServer = await MongoMemoryServer.create(); 34 const uri = mongoServer.getUri(); 35 + 36 + await connect(uri, dbName); 37 + activeDbName = dbName; 38 isSetup = true; 39 + } catch (error) { 40 + // Roll back refcount if setup failed so future attempts can retry 41 + setupRefCount = Math.max(0, setupRefCount - 1); 42 + throw error; 43 } 44 } 45 46 export async function teardownTestDb() { 47 + if (setupRefCount === 0) { 48 + return; 49 + } 50 + 51 + setupRefCount = Math.max(0, setupRefCount - 1); 52 + 53 + if (isSetup && setupRefCount === 0) { 54 await disconnect(); 55 if (mongoServer) { 56 await mongoServer.stop(); 57 mongoServer = null; 58 } 59 + activeDbName = null; 60 isSetup = false; 61 } 62 } 63 64 + export function createUserModel( 65 + collectionName = "users", 66 + ): Model<typeof userSchema> { 67 + return new Model(collectionName, userSchema); 68 } 69 70 export async function cleanupCollection(model: Model<typeof userSchema>) { 71 await model.delete({}); 72 }
+1 -8
tests/validation_test.ts
··· 14 15 Deno.test.beforeAll(async () => { 16 await setupTestDb(); 17 - UserModel = createUserModel(); 18 }); 19 20 Deno.test.beforeEach(async () => { ··· 28 Deno.test({ 29 name: "Validation: Schema - should validate user data on insert", 30 async fn() { 31 - 32 const invalidUser = { 33 name: "Invalid User", 34 email: "not-an-email", // Invalid email ··· 50 Deno.test({ 51 name: "Validation: Update - should reject invalid email in update", 52 async fn() { 53 - 54 // Insert a user for this test 55 const newUser: UserInsert = { 56 name: "Validation Test User", ··· 79 Deno.test({ 80 name: "Validation: Update - should reject negative age in update", 81 async fn() { 82 - 83 // Insert a user for this test 84 const newUser: UserInsert = { 85 name: "Age Validation Test User", ··· 108 Deno.test({ 109 name: "Validation: Update - should reject invalid name type in update", 110 async fn() { 111 - 112 // Insert a user for this test 113 const newUser: UserInsert = { 114 name: "Type Validation Test User", ··· 137 Deno.test({ 138 name: "Validation: Update - should accept valid partial updates", 139 async fn() { 140 - 141 // Insert a user for this test 142 const newUser: UserInsert = { 143 name: "Valid Update Test User", ··· 168 sanitizeResources: false, 169 sanitizeOps: false, 170 }); 171 - 172 -
··· 14 15 Deno.test.beforeAll(async () => { 16 await setupTestDb(); 17 + UserModel = createUserModel("users_validation"); 18 }); 19 20 Deno.test.beforeEach(async () => { ··· 28 Deno.test({ 29 name: "Validation: Schema - should validate user data on insert", 30 async fn() { 31 const invalidUser = { 32 name: "Invalid User", 33 email: "not-an-email", // Invalid email ··· 49 Deno.test({ 50 name: "Validation: Update - should reject invalid email in update", 51 async fn() { 52 // Insert a user for this test 53 const newUser: UserInsert = { 54 name: "Validation Test User", ··· 77 Deno.test({ 78 name: "Validation: Update - should reject negative age in update", 79 async fn() { 80 // Insert a user for this test 81 const newUser: UserInsert = { 82 name: "Age Validation Test User", ··· 105 Deno.test({ 106 name: "Validation: Update - should reject invalid name type in update", 107 async fn() { 108 // Insert a user for this test 109 const newUser: UserInsert = { 110 name: "Type Validation Test User", ··· 133 Deno.test({ 134 name: "Validation: Update - should accept valid partial updates", 135 async fn() { 136 // Insert a user for this test 137 const newUser: UserInsert = { 138 name: "Valid Update Test User", ··· 163 sanitizeResources: false, 164 sanitizeOps: false, 165 });
+5 -6
types.ts
··· 1 import type { z } from "@zod/zod"; 2 - import type { Document, ObjectId, IndexDescription } from "mongodb"; 3 4 /** 5 * Type alias for Zod schema objects ··· 11 */ 12 export type Infer<T extends Schema> = z.infer<T> & Document; 13 14 - 15 /** 16 * Infer the model type from a Zod schema, including MongoDB Document and ObjectId 17 */ 18 export type InferModel<T extends Schema> = Infer<T> & { 19 - _id?: ObjectId; 20 - }; 21 22 /** 23 * Infer the input type for a Zod schema (handles defaults) ··· 31 32 /** 33 * Complete definition of a model, including schema and indexes 34 - * 35 * @example 36 * ```ts 37 * const userDef: ModelDef<typeof userSchema> = { ··· 46 export type ModelDef<T extends Schema> = { 47 schema: T; 48 indexes?: Indexes; 49 - };
··· 1 import type { z } from "@zod/zod"; 2 + import type { Document, IndexDescription, ObjectId } from "mongodb"; 3 4 /** 5 * Type alias for Zod schema objects ··· 11 */ 12 export type Infer<T extends Schema> = z.infer<T> & Document; 13 14 /** 15 * Infer the model type from a Zod schema, including MongoDB Document and ObjectId 16 */ 17 export type InferModel<T extends Schema> = Infer<T> & { 18 + _id?: ObjectId; 19 + }; 20 21 /** 22 * Infer the input type for a Zod schema (handles defaults) ··· 30 31 /** 32 * Complete definition of a model, including schema and indexes 33 + * 34 * @example 35 * ```ts 36 * const userDef: ModelDef<typeof userSchema> = { ··· 45 export type ModelDef<T extends Schema> = { 46 schema: T; 47 indexes?: Indexes; 48 + };