Thin MongoDB ODM built for Standard Schema
mongodb zod deno

bench

knotbin.com 93878ec0 a9f7213d

verified
+2 -2
.vscode/settings.json
···
{
-
"git.enabled": false
-
}
+
"git.enabled": false
+
}
+32 -31
README.md
···
## ✨ Features
-
- **Schema-first:** Define and validate collections using [Zod](https://zod.dev).
+
- **Schema-first:** Define and validate collections using
+
[Zod](https://zod.dev).
- **Type-safe operations:** Auto-complete and strict typings for `insert`,
`find`, `update`, and `delete`.
- **Minimal & modular:** No decorators or magic. Just clean, composable APIs.
···
```ts
// src/index.ts
-
import {
-
connect,
-
disconnect,
-
InferModel,
-
Input,
-
Model,
-
} from "@nozzle/nozzle";
+
import { connect, disconnect, InferModel, Input, Model } from "@nozzle/nozzle";
import { userSchema } from "./schemas/user";
import { ObjectId } from "mongodb"; // v6+ driver recommended
···
async function main() {
// Basic connection
await connect("mongodb://localhost:27017", "your_database_name");
-
+
// Or with connection pooling options
await connect("mongodb://localhost:27017", "your_database_name", {
-
maxPoolSize: 10, // Maximum connections in pool
-
minPoolSize: 2, // Minimum connections in pool
-
maxIdleTimeMS: 30000, // Close idle connections after 30s
+
maxPoolSize: 10, // Maximum connections in pool
+
minPoolSize: 2, // Minimum connections in pool
+
maxIdleTimeMS: 30000, // Close idle connections after 30s
connectTimeoutMS: 10000, // Connection timeout
-
socketTimeoutMS: 45000, // Socket timeout
+
socketTimeoutMS: 45000, // Socket timeout
});
-
+
// Production-ready connection with retry logic and resilience
await connect("mongodb://localhost:27017", "your_database_name", {
// Connection pooling
maxPoolSize: 10,
minPoolSize: 2,
-
+
// Automatic retry logic (enabled by default)
-
retryReads: true, // Retry failed read operations
-
retryWrites: true, // Retry failed write operations
-
+
retryReads: true, // Retry failed read operations
+
retryWrites: true, // Retry failed write operations
+
// Timeouts
-
connectTimeoutMS: 10000, // Initial connection timeout
-
socketTimeoutMS: 45000, // Socket operation timeout
+
connectTimeoutMS: 10000, // Initial connection timeout
+
socketTimeoutMS: 45000, // Socket operation timeout
serverSelectionTimeoutMS: 10000, // Server selection timeout
-
+
// Connection resilience
-
maxIdleTimeMS: 30000, // Close idle connections
+
maxIdleTimeMS: 30000, // Close idle connections
heartbeatFrequencyMS: 10000, // Server health check interval
});
-
+
const UserModel = new Model("users", userSchema);
// Your operations go here
···
// All operations in this callback are part of the same transaction
const user = await UserModel.insertOne(
{ name: "Alice", email: "alice@example.com" },
-
{ session } // Pass session to each operation
+
{ session }, // Pass session to each operation
);
-
+
const order = await OrderModel.insertOne(
{ userId: user.insertedId, total: 100 },
-
{ session }
+
{ session },
);
-
+
// If any operation fails, the entire transaction is automatically aborted
// If callback succeeds, transaction is automatically committed
return { user, order };
});
// Manual session management (for advanced use cases)
-
import { startSession, endSession } from "@nozzle/nozzle";
+
import { endSession, startSession } from "@nozzle/nozzle";
const session = startSession();
try {
await session.withTransaction(async () => {
-
await UserModel.insertOne({ name: "Bob", email: "bob@example.com" }, { session });
+
await UserModel.insertOne({ name: "Bob", email: "bob@example.com" }, {
+
session,
+
});
await UserModel.updateOne({ name: "Alice" }, { balance: 50 }, { session });
});
} finally {
···
}
// Error Handling
-
import { ValidationError, ConnectionError } from "@nozzle/nozzle";
+
import { ConnectionError, ValidationError } from "@nozzle/nozzle";
try {
await UserModel.insertOne({ name: "", email: "invalid" });
···
## 🗺️ Roadmap
### 🔴 Critical (Must Have)
+
- [x] Transactions support
- [x] Connection retry logic
- [x] Improved error handling
···
- [x] Connection pooling configuration
### 🟡 Important (Should Have)
+
- [x] Index management
- [ ] Middleware/hooks system
- [ ] Relationship/population support
···
- [ ] Comprehensive edge case testing
### 🟢 Nice to Have
+
- [x] Pagination support
- [ ] Plugin system
- [ ] Query builder API
- [ ] Virtual fields
- [ ] Document/static methods
-
For detailed production readiness assessment, see [PRODUCTION_READINESS_ASSESSMENT.md](./PRODUCTION_READINESS_ASSESSMENT.md).
+
For detailed production readiness assessment, see
+
[PRODUCTION_READINESS_ASSESSMENT.md](./PRODUCTION_READINESS_ASSESSMENT.md).
---
+124
bench/ops_bench.ts
···
+
import { z } from "@zod/zod";
+
import { MongoMemoryServer } from "mongodb-memory-server-core";
+
import mongoose from "mongoose";
+
import { connect, disconnect, Model } from "../mod.ts";
+
+
/**
+
* Benchmark basic CRUD operations for Nozzle vs Mongoose.
+
*
+
* Run with:
+
* deno bench -A bench/nozzle_vs_mongoose.bench.ts
+
*/
+
+
const userSchema = z.object({
+
name: z.string(),
+
email: z.string().email(),
+
age: z.number().int().positive().optional(),
+
createdAt: z.date().default(() => new Date()),
+
});
+
+
const mongoServer = await MongoMemoryServer.create();
+
const uri = mongoServer.getUri();
+
+
// Use separate DBs to avoid any cross-driver interference
+
const nozzleDbName = "bench_nozzle";
+
const mongooseDbName = "bench_mongoose";
+
+
await connect(uri, nozzleDbName);
+
const NozzleUser = new Model("bench_users_nozzle", userSchema);
+
+
const mongooseConn = await mongoose.connect(uri, { dbName: mongooseDbName });
+
const mongooseUserSchema = new mongoose.Schema(
+
{
+
name: String,
+
email: String,
+
age: Number,
+
createdAt: { type: Date, default: Date.now },
+
},
+
{ collection: "bench_users_mongoose" },
+
);
+
const MongooseUser = mongooseConn.models.BenchUser ||
+
mongooseConn.model("BenchUser", mongooseUserSchema);
+
+
// Start from a clean state
+
await NozzleUser.delete({});
+
await MongooseUser.deleteMany({});
+
+
// Seed base documents for read/update benches
+
const nozzleSeed = await NozzleUser.insertOne({
+
name: "Seed Nozzle",
+
email: "seed-nozzle@example.com",
+
age: 30,
+
});
+
const mongooseSeed = await MongooseUser.create({
+
name: "Seed Mongoose",
+
email: "seed-mongoose@example.com",
+
age: 30,
+
});
+
+
const nozzleSeedId = nozzleSeed.insertedId;
+
const mongooseSeedId = mongooseSeed._id;
+
+
let counter = 0;
+
const nextEmail = (prefix: string) => `${prefix}-${counter++}@bench.dev`;
+
+
Deno.bench("mongoose insertOne", { group: "insertOne" }, async () => {
+
await MongooseUser.insertOne({
+
name: "Mongoose User",
+
email: nextEmail("mongoose"),
+
age: 25,
+
});
+
});
+
+
Deno.bench(
+
"nozzle insertOne",
+
{ group: "insertOne", baseline: true },
+
async () => {
+
await NozzleUser.insertOne({
+
name: "Nozzle User",
+
email: nextEmail("nozzle"),
+
age: 25,
+
});
+
},
+
);
+
+
Deno.bench("mongoose findById", { group: "findById" }, async () => {
+
await MongooseUser.findById(mongooseSeedId);
+
});
+
+
Deno.bench(
+
"nozzle findById",
+
{ group: "findById", baseline: true },
+
async () => {
+
await NozzleUser.findById(nozzleSeedId);
+
},
+
);
+
+
Deno.bench("mongoose updateOne", { group: "updateOne" }, async () => {
+
await MongooseUser.updateOne(
+
{ _id: mongooseSeedId },
+
{ $set: { age: 31 } },
+
);
+
});
+
+
Deno.bench(
+
"nozzle updateOne",
+
{ group: "updateOne", baseline: true },
+
async () => {
+
await NozzleUser.updateOne(
+
{ _id: nozzleSeedId },
+
{ age: 31 },
+
);
+
},
+
);
+
+
// Attempt graceful shutdown when the process exits
+
async function cleanup() {
+
await disconnect();
+
await mongooseConn.disconnect();
+
await mongoServer.stop();
+
}
+
+
globalThis.addEventListener("unload", () => {
+
void cleanup();
+
});
+139
bench/results.json
···
+
{
+
"version": 1,
+
"runtime": "Deno/2.5.6 aarch64-apple-darwin",
+
"cpu": "Apple M2 Pro",
+
"benches": [
+
{
+
"origin": "file:///Users/knotbin/Developer/nozzle/bench/ops_bench.ts",
+
"group": "insertOne",
+
"name": "mongoose insertOne",
+
"baseline": false,
+
"results": [
+
{
+
"ok": {
+
"n": 3733,
+
"min": 85750.0,
+
"max": 495459.0,
+
"avg": 134257.0,
+
"p75": 128917.0,
+
"p99": 313291.0,
+
"p995": 344708.0,
+
"p999": 446833.0,
+
"highPrecision": true,
+
"usedExplicitTimers": false
+
}
+
}
+
]
+
},
+
{
+
"origin": "file:///Users/knotbin/Developer/nozzle/bench/ops_bench.ts",
+
"group": "insertOne",
+
"name": "nozzle insertOne",
+
"baseline": true,
+
"results": [
+
{
+
"ok": {
+
"n": 6354,
+
"min": 52667.0,
+
"max": 453875.0,
+
"avg": 78809.0,
+
"p75": 81417.0,
+
"p99": 149417.0,
+
"p995": 201459.0,
+
"p999": 274750.0,
+
"highPrecision": true,
+
"usedExplicitTimers": false
+
}
+
}
+
]
+
},
+
{
+
"origin": "file:///Users/knotbin/Developer/nozzle/bench/ops_bench.ts",
+
"group": "findById",
+
"name": "mongoose findById",
+
"baseline": false,
+
"results": [
+
{
+
"ok": {
+
"n": 3707,
+
"min": 113875.0,
+
"max": 510125.0,
+
"avg": 135223.0,
+
"p75": 137167.0,
+
"p99": 263958.0,
+
"p995": 347458.0,
+
"p999": 428500.0,
+
"highPrecision": true,
+
"usedExplicitTimers": false
+
}
+
}
+
]
+
},
+
{
+
"origin": "file:///Users/knotbin/Developer/nozzle/bench/ops_bench.ts",
+
"group": "findById",
+
"name": "nozzle findById",
+
"baseline": true,
+
"results": [
+
{
+
"ok": {
+
"n": 6045,
+
"min": 70750.0,
+
"max": 1008792.0,
+
"avg": 82859.0,
+
"p75": 83750.0,
+
"p99": 132250.0,
+
"p995": 183500.0,
+
"p999": 311833.0,
+
"highPrecision": true,
+
"usedExplicitTimers": false
+
}
+
}
+
]
+
},
+
{
+
"origin": "file:///Users/knotbin/Developer/nozzle/bench/ops_bench.ts",
+
"group": "updateOne",
+
"name": "mongoose updateOne",
+
"baseline": false,
+
"results": [
+
{
+
"ok": {
+
"n": 4123,
+
"min": 98500.0,
+
"max": 717334.0,
+
"avg": 121572.0,
+
"p75": 123292.0,
+
"p99": 179375.0,
+
"p995": 281417.0,
+
"p999": 342625.0,
+
"highPrecision": true,
+
"usedExplicitTimers": false
+
}
+
}
+
]
+
},
+
{
+
"origin": "file:///Users/knotbin/Developer/nozzle/bench/ops_bench.ts",
+
"group": "updateOne",
+
"name": "nozzle updateOne",
+
"baseline": true,
+
"results": [
+
{
+
"ok": {
+
"n": 6550,
+
"min": 53833.0,
+
"max": 401667.0,
+
"avg": 76456.0,
+
"p75": 76834.0,
+
"p99": 118292.0,
+
"p995": 181500.0,
+
"p999": 299958.0,
+
"highPrecision": true,
+
"usedExplicitTimers": false
+
}
+
}
+
]
+
}
+
]
+
}
+16 -14
client/connection.ts
···
-
import { type Db, type MongoClientOptions, MongoClient } from "mongodb";
+
import { type Db, MongoClient, type MongoClientOptions } from "mongodb";
import { ConnectionError } from "../errors.ts";
/**
* Connection management module
-
*
+
*
* Handles MongoDB connection lifecycle including connect, disconnect,
* and connection state management.
*/
···
/**
* Connect to MongoDB with connection pooling, retry logic, and resilience options
-
*
+
*
* The MongoDB driver handles connection pooling and automatic retries.
* Retry logic is enabled by default for both reads and writes in MongoDB 4.2+.
-
*
+
*
* @param uri - MongoDB connection string
* @param dbName - Name of the database to connect to
* @param options - Connection options (pooling, retries, timeouts, etc.)
* @returns Connection object with client and db
-
*
+
*
* @example
* Basic connection with pooling:
* ```ts
···
* socketTimeoutMS: 45000,
* });
* ```
-
*
+
*
* @example
* Production-ready connection with retry logic and resilience:
* ```ts
···
* // Connection pooling
* maxPoolSize: 10,
* minPoolSize: 2,
-
*
+
*
* // Automatic retry logic (enabled by default)
* retryReads: true, // Retry failed read operations
* retryWrites: true, // Retry failed write operations
-
*
+
*
* // Timeouts
* connectTimeoutMS: 10000, // Initial connection timeout
* socketTimeoutMS: 45000, // Socket operation timeout
* serverSelectionTimeoutMS: 10000, // Server selection timeout
-
*
+
*
* // Connection resilience
* maxIdleTimeMS: 30000, // Close idle connections
* heartbeatFrequencyMS: 10000, // Server health check interval
-
*
+
*
* // Optional: Compression for reduced bandwidth
* compressors: ['snappy', 'zlib'],
* });
···
return connection;
} catch (error) {
throw new ConnectionError(
-
`Failed to connect to MongoDB: ${error instanceof Error ? error.message : String(error)}`,
-
uri
+
`Failed to connect to MongoDB: ${
+
error instanceof Error ? error.message : String(error)
+
}`,
+
uri,
);
}
}
···
/**
* Get the current database connection
-
*
+
*
* @returns MongoDB Db instance
* @throws {ConnectionError} If not connected
* @internal
···
/**
* Get the current connection state
-
*
+
*
* @returns Connection object or null if not connected
* @internal
*/
+5 -5
client/health.ts
···
/**
* Health check module
-
*
+
*
* Provides functionality for monitoring MongoDB connection health
* including ping operations and response time measurement.
*/
/**
* Health check details of the MongoDB connection
-
*
+
*
* @property healthy - Overall health status of the connection
* @property connected - Whether a connection is established
* @property responseTimeMs - Response time in milliseconds (if connection is healthy)
···
/**
* Check the health of the MongoDB connection
-
*
+
*
* Performs a ping operation to verify the database is responsive
* and returns detailed health information including response time.
-
*
+
*
* @returns Health check result with status and metrics
-
*
+
*
* @example
* ```ts
* const health = await healthCheck();
+5 -12
client/index.ts
···
/**
* Client module - MongoDB connection and session management
-
*
+
*
* This module provides all client-level functionality including:
* - Connection management (connect, disconnect)
* - Health monitoring (healthCheck)
···
// Re-export connection management
export {
connect,
+
type Connection,
+
type ConnectOptions,
disconnect,
getDb,
-
type ConnectOptions,
-
type Connection,
} from "./connection.ts";
// Re-export health monitoring
-
export {
-
healthCheck,
-
type HealthCheckResult,
-
} from "./health.ts";
+
export { healthCheck, type HealthCheckResult } from "./health.ts";
// Re-export transaction management
-
export {
-
startSession,
-
endSession,
-
withTransaction,
-
} from "./transactions.ts";
+
export { endSession, startSession, withTransaction } from "./transactions.ts";
+12 -12
client/transactions.ts
···
/**
* Transaction management module
-
*
+
*
* Provides session and transaction management functionality including
* automatic transaction handling and manual session control.
*/
/**
* Start a new client session for transactions
-
*
+
*
* Sessions must be ended when done using `endSession()`
-
*
+
*
* @returns New MongoDB ClientSession
* @throws {ConnectionError} If not connected
-
*
+
*
* @example
* ```ts
* const session = startSession();
···
/**
* End a client session
-
*
+
*
* @param session - The session to end
*/
export async function endSession(session: ClientSession): Promise<void> {
···
/**
* Execute a function within a transaction
-
*
+
*
* Automatically handles session creation, transaction start/commit/abort, and cleanup.
* If the callback throws an error, the transaction is automatically aborted.
-
*
+
*
* @param callback - Async function to execute within the transaction. Receives the session as parameter.
* @param options - Optional transaction options (read/write concern, etc.)
* @returns The result from the callback function
-
*
+
*
* @example
* ```ts
* const result = await withTransaction(async (session) => {
···
*/
export async function withTransaction<T>(
callback: (session: ClientSession) => Promise<T>,
-
options?: TransactionOptions
+
options?: TransactionOptions,
): Promise<T> {
const session = startSession();
-
+
try {
let result: T;
-
+
await session.withTransaction(async () => {
result = await callback(session);
}, options);
-
+
return result!;
} finally {
await endSession(session);
+2 -1
deno.json
···
"@std/assert": "jsr:@std/assert@^1.0.16",
"@zod/zod": "jsr:@zod/zod@^4.1.13",
"mongodb": "npm:mongodb@^6.18.0",
-
"mongodb-memory-server-core": "npm:mongodb-memory-server-core@^10.3.0"
+
"mongodb-memory-server-core": "npm:mongodb-memory-server-core@^10.3.0",
+
"mongoose": "npm:mongoose@^8.5.2"
}
}
+40 -3
deno.lock
···
"jsr:@zod/zod@^4.1.13": "4.1.13",
"npm:@types/node@*": "22.15.15",
"npm:mongodb-memory-server-core@^10.3.0": "10.3.0",
-
"npm:mongodb@^6.18.0": "6.18.0"
+
"npm:mongodb@^6.18.0": "6.20.0",
+
"npm:mongoose@^8.5.2": "8.20.1"
},
"jsr": {
"@std/assert@1.0.13": {
···
"debug"
]
},
+
"kareem@2.6.3": {
+
"integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q=="
+
},
"locate-path@5.0.0": {
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dependencies": [
···
"find-cache-dir",
"follow-redirects",
"https-proxy-agent",
-
"mongodb",
+
"mongodb@6.18.0",
"new-find-package-json",
"semver@7.7.3",
"tar-stream",
···
"mongodb-connection-string-url"
]
},
+
"mongodb@6.20.0": {
+
"integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==",
+
"dependencies": [
+
"@mongodb-js/saslprep",
+
"bson",
+
"mongodb-connection-string-url"
+
]
+
},
+
"mongoose@8.20.1": {
+
"integrity": "sha512-G+n3maddlqkQrP1nXxsI0q20144OSo+pe+HzRRGqaC4yK3FLYKqejqB9cbIi+SX7eoRsnG23LHGYNp8n7mWL2Q==",
+
"dependencies": [
+
"bson",
+
"kareem",
+
"mongodb@6.20.0",
+
"mpath",
+
"mquery",
+
"ms",
+
"sift"
+
]
+
},
+
"mpath@0.9.0": {
+
"integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew=="
+
},
+
"mquery@5.0.0": {
+
"integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==",
+
"dependencies": [
+
"debug"
+
]
+
},
"ms@2.1.3": {
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
···
"semver@7.7.3": {
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"bin": true
+
},
+
"sift@17.1.3": {
+
"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ=="
},
"sparse-bitfield@3.0.3": {
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
···
"jsr:@std/assert@^1.0.16",
"jsr:@zod/zod@^4.1.13",
"npm:mongodb-memory-server-core@^10.3.0",
-
"npm:mongodb@^6.18.0"
+
"npm:mongodb@^6.18.0",
+
"npm:mongoose@^8.5.2"
]
}
}
+21 -13
errors.ts
···
export class ValidationError extends NozzleError {
public readonly issues: ValidationIssue[];
public readonly operation: "insert" | "update" | "replace";
-
-
constructor(issues: ValidationIssue[], operation: "insert" | "update" | "replace") {
+
+
constructor(
+
issues: ValidationIssue[],
+
operation: "insert" | "update" | "replace",
+
) {
const message = ValidationError.formatIssues(issues);
super(`Validation failed on ${operation}: ${message}`);
this.issues = issues;
···
}
private static formatIssues(issues: ValidationIssue[]): string {
-
return issues.map(issue => {
-
const path = issue.path.join('.');
-
return `${path || 'root'}: ${issue.message}`;
-
}).join('; ');
+
return issues.map((issue) => {
+
const path = issue.path.join(".");
+
return `${path || "root"}: ${issue.message}`;
+
}).join("; ");
}
/**
···
public getFieldErrors(): Record<string, string[]> {
const fieldErrors: Record<string, string[]> = {};
for (const issue of this.issues) {
-
const field = issue.path.join('.') || 'root';
+
const field = issue.path.join(".") || "root";
if (!fieldErrors[field]) {
fieldErrors[field] = [];
}
···
*/
export class ConnectionError extends NozzleError {
public readonly uri?: string;
-
+
constructor(message: string, uri?: string) {
super(message);
this.uri = uri;
···
*/
export class ConfigurationError extends NozzleError {
public readonly option?: string;
-
+
constructor(message: string, option?: string) {
super(message);
this.option = option;
···
export class DocumentNotFoundError extends NozzleError {
public readonly query: unknown;
public readonly collection: string;
-
+
constructor(collection: string, query: unknown) {
super(`Document not found in collection '${collection}'`);
this.collection = collection;
···
public readonly operation: string;
public readonly collection?: string;
public override readonly cause?: Error;
-
-
constructor(operation: string, message: string, collection?: string, cause?: Error) {
+
+
constructor(
+
operation: string,
+
message: string,
+
collection?: string,
+
cause?: Error,
+
) {
super(`${operation} operation failed: ${message}`);
this.operation = operation;
this.collection = collection;
···
constructor() {
super(
"Async validation is not currently supported. " +
-
"Please use synchronous validation schemas."
+
"Please use synchronous validation schemas.",
);
}
}
+12 -12
mod.ts
···
-
export type { Schema, Infer, Input } from "./types.ts";
-
export {
-
connect,
-
disconnect,
-
healthCheck,
-
startSession,
+
export type { Infer, Input, Schema } from "./types.ts";
+
export {
+
connect,
+
type ConnectOptions,
+
disconnect,
endSession,
+
healthCheck,
+
type HealthCheckResult,
+
startSession,
withTransaction,
-
type ConnectOptions,
-
type HealthCheckResult
} from "./client/index.ts";
export { Model } from "./model/index.ts";
export {
-
NozzleError,
-
ValidationError,
+
AsyncValidationError,
+
ConfigurationError,
ConnectionError,
-
ConfigurationError,
DocumentNotFoundError,
+
NozzleError,
OperationError,
-
AsyncValidationError,
+
ValidationError,
} from "./errors.ts";
// Re-export MongoDB types that users might need
+81 -62
model/core.ts
···
import type { z } from "@zod/zod";
import type {
+
AggregateOptions,
+
BulkWriteOptions,
Collection,
+
CountDocumentsOptions,
+
DeleteOptions,
DeleteResult,
Document,
Filter,
+
FindOneAndReplaceOptions,
+
FindOneAndUpdateOptions,
+
FindOptions,
InsertManyResult,
-
InsertOneResult,
InsertOneOptions,
-
FindOptions,
-
UpdateOptions,
-
ReplaceOptions,
-
FindOneAndUpdateOptions,
-
FindOneAndReplaceOptions,
-
DeleteOptions,
-
CountDocumentsOptions,
-
AggregateOptions,
+
InsertOneResult,
+
ModifyResult,
OptionalUnlessRequiredId,
+
ReplaceOptions,
+
UpdateFilter,
+
UpdateOptions,
UpdateResult,
WithId,
-
BulkWriteOptions,
-
UpdateFilter,
-
ModifyResult,
} from "mongodb";
import { ObjectId } from "mongodb";
-
import type { Schema, Infer, Input } from "../types.ts";
-
import { parse, parsePartial, parseReplace, applyDefaultsForUpsert } from "./validation.ts";
+
import type { Infer, Input, Schema } from "../types.ts";
+
import {
+
applyDefaultsForUpsert,
+
parse,
+
parsePartial,
+
parseReplace,
+
} from "./validation.ts";
/**
* Core CRUD operations for the Model class
-
*
+
*
* This module contains all basic create, read, update, and delete operations
* with automatic Zod validation and transaction support.
*/
/**
* Insert a single document into the collection
-
*
+
*
* @param collection - MongoDB collection
* @param schema - Zod schema for validation
* @param data - Document data to insert
···
collection: Collection<Infer<T>>,
schema: T,
data: Input<T>,
-
options?: InsertOneOptions
+
options?: InsertOneOptions,
): Promise<InsertOneResult<Infer<T>>> {
const validatedData = parse(schema, data);
return await collection.insertOne(
validatedData as OptionalUnlessRequiredId<Infer<T>>,
-
options
+
options,
);
}
/**
* Insert multiple documents into the collection
-
*
+
*
* @param collection - MongoDB collection
* @param schema - Zod schema for validation
* @param data - Array of document data to insert
···
collection: Collection<Infer<T>>,
schema: T,
data: Input<T>[],
-
options?: BulkWriteOptions
+
options?: BulkWriteOptions,
): Promise<InsertManyResult<Infer<T>>> {
const validatedData = data.map((item) => parse(schema, item));
return await collection.insertMany(
validatedData as OptionalUnlessRequiredId<Infer<T>>[],
-
options
+
options,
);
}
/**
* Find multiple documents matching the query
-
*
+
*
* @param collection - MongoDB collection
* @param query - MongoDB query filter
* @param options - Find options (including session for transactions)
···
export async function find<T extends Schema>(
collection: Collection<Infer<T>>,
query: Filter<Infer<T>>,
-
options?: FindOptions
+
options?: FindOptions,
): Promise<(WithId<Infer<T>>)[]> {
return await collection.find(query, options).toArray();
}
/**
* Find a single document matching the query
-
*
+
*
* @param collection - MongoDB collection
* @param query - MongoDB query filter
* @param options - Find options (including session for transactions)
···
export async function findOne<T extends Schema>(
collection: Collection<Infer<T>>,
query: Filter<Infer<T>>,
-
options?: FindOptions
+
options?: FindOptions,
): Promise<WithId<Infer<T>> | null> {
return await collection.findOne(query, options);
}
/**
* Find a document by its MongoDB ObjectId
-
*
+
*
* @param collection - MongoDB collection
* @param id - Document ID (string or ObjectId)
* @param options - Find options (including session for transactions)
···
export async function findById<T extends Schema>(
collection: Collection<Infer<T>>,
id: string | ObjectId,
-
options?: FindOptions
+
options?: FindOptions,
): Promise<WithId<Infer<T>> | null> {
const objectId = typeof id === "string" ? new ObjectId(id) : id;
-
return await findOne(collection, { _id: objectId } as Filter<Infer<T>>, options);
+
return await findOne(
+
collection,
+
{ _id: objectId } as Filter<Infer<T>>,
+
options,
+
);
}
/**
* Update multiple documents matching the query
-
*
+
*
* Case handling:
* - If upsert: false (or undefined) → Normal update, no defaults applied
* - If upsert: true → Defaults added to $setOnInsert for new document creation
-
*
+
*
* @param collection - MongoDB collection
* @param schema - Zod schema for validation
* @param query - MongoDB query filter
···
schema: T,
query: Filter<Infer<T>>,
data: Partial<z.infer<T>>,
-
options?: UpdateOptions
+
options?: UpdateOptions,
): Promise<UpdateResult<Infer<T>>> {
const validatedData = parsePartial(schema, data);
-
let updateDoc: UpdateFilter<Infer<T>> = { $set: validatedData as Partial<Infer<T>> };
-
+
let updateDoc: UpdateFilter<Infer<T>> = {
+
$set: validatedData as Partial<Infer<T>>,
+
};
+
// If this is an upsert, apply defaults using $setOnInsert
if (options?.upsert) {
updateDoc = applyDefaultsForUpsert(schema, query, updateDoc);
}
-
+
return await collection.updateMany(query, updateDoc, options);
}
/**
* Update a single document matching the query
-
*
+
*
* Case handling:
* - If upsert: false (or undefined) → Normal update, no defaults applied
* - If upsert: true → Defaults added to $setOnInsert for new document creation
-
*
+
*
* @param collection - MongoDB collection
* @param schema - Zod schema for validation
* @param query - MongoDB query filter
···
schema: T,
query: Filter<Infer<T>>,
data: Partial<z.infer<T>>,
-
options?: UpdateOptions
+
options?: UpdateOptions,
): Promise<UpdateResult<Infer<T>>> {
const validatedData = parsePartial(schema, data);
-
let updateDoc: UpdateFilter<Infer<T>> = { $set: validatedData as Partial<Infer<T>> };
-
+
let updateDoc: UpdateFilter<Infer<T>> = {
+
$set: validatedData as Partial<Infer<T>>,
+
};
+
// If this is an upsert, apply defaults using $setOnInsert
if (options?.upsert) {
updateDoc = applyDefaultsForUpsert(schema, query, updateDoc);
}
-
+
return await collection.updateOne(query, updateDoc, options);
}
/**
* Replace a single document matching the query
-
*
+
*
* Case handling:
* - If upsert: false (or undefined) → Normal replace on existing doc, no additional defaults
* - If upsert: true → Defaults applied via parse() since we're passing a full document
-
*
+
*
* Note: For replace operations, defaults are automatically applied by the schema's
* parse() function which treats missing fields as candidates for defaults. This works
* for both regular replaces and upsert-creates since we're providing a full document.
-
*
+
*
* @param collection - MongoDB collection
* @param schema - Zod schema for validation
* @param query - MongoDB query filter
···
schema: T,
query: Filter<Infer<T>>,
data: Input<T>,
-
options?: ReplaceOptions
+
options?: ReplaceOptions,
): Promise<UpdateResult<Infer<T>>> {
// parseReplace will apply all schema defaults to missing fields
// This works correctly for both regular replaces and upsert-created documents
const validatedData = parseReplace(schema, data);
-
+
// Remove _id from validatedData for replaceOne (it will use the query's _id)
const { _id, ...withoutId } = validatedData as Infer<T> & { _id?: unknown };
return await collection.replaceOne(
query,
withoutId as Infer<T>,
-
options
+
options,
);
}
/**
* Find a single document and update it
-
*
+
*
* Case handling:
* - If upsert: false (or undefined) → Normal update
* - If upsert: true → Defaults added to $setOnInsert for new document creation
···
schema: T,
query: Filter<Infer<T>>,
data: Partial<z.infer<T>>,
-
options?: FindOneAndUpdateOptions
+
options?: FindOneAndUpdateOptions,
): Promise<ModifyResult<Infer<T>>> {
const validatedData = parsePartial(schema, data);
-
let updateDoc: UpdateFilter<Infer<T>> = { $set: validatedData as Partial<Infer<T>> };
+
let updateDoc: UpdateFilter<Infer<T>> = {
+
$set: validatedData as Partial<Infer<T>>,
+
};
if (options?.upsert) {
updateDoc = applyDefaultsForUpsert(schema, query, updateDoc);
}
-
const resolvedOptions: FindOneAndUpdateOptions & { includeResultMetadata: true } = {
+
const resolvedOptions: FindOneAndUpdateOptions & {
+
includeResultMetadata: true;
+
} = {
...(options ?? {}),
includeResultMetadata: true as const,
};
···
/**
* Find a single document and replace it
-
*
+
*
* Defaults are applied via parseReplace(), which fills in missing fields
* for both normal replacements and upsert-created documents.
*/
···
schema: T,
query: Filter<Infer<T>>,
data: Input<T>,
-
options?: FindOneAndReplaceOptions
+
options?: FindOneAndReplaceOptions,
): Promise<ModifyResult<Infer<T>>> {
const validatedData = parseReplace(schema, data);
const { _id, ...withoutId } = validatedData as Infer<T> & { _id?: unknown };
-
const resolvedOptions: FindOneAndReplaceOptions & { includeResultMetadata: true } = {
+
const resolvedOptions: FindOneAndReplaceOptions & {
+
includeResultMetadata: true;
+
} = {
...(options ?? {}),
includeResultMetadata: true as const,
};
···
return await collection.findOneAndReplace(
query,
withoutId as Infer<T>,
-
resolvedOptions
+
resolvedOptions,
);
}
/**
* Delete multiple documents matching the query
-
*
+
*
* @param collection - MongoDB collection
* @param query - MongoDB query filter
* @param options - Delete options (including session for transactions)
···
export async function deleteMany<T extends Schema>(
collection: Collection<Infer<T>>,
query: Filter<Infer<T>>,
-
options?: DeleteOptions
+
options?: DeleteOptions,
): Promise<DeleteResult> {
return await collection.deleteMany(query, options);
}
/**
* Delete a single document matching the query
-
*
+
*
* @param collection - MongoDB collection
* @param query - MongoDB query filter
* @param options - Delete options (including session for transactions)
···
export async function deleteOne<T extends Schema>(
collection: Collection<Infer<T>>,
query: Filter<Infer<T>>,
-
options?: DeleteOptions
+
options?: DeleteOptions,
): Promise<DeleteResult> {
return await collection.deleteOne(query, options);
}
/**
* Count documents matching the query
-
*
+
*
* @param collection - MongoDB collection
* @param query - MongoDB query filter
* @param options - Count options (including session for transactions)
···
export async function count<T extends Schema>(
collection: Collection<Infer<T>>,
query: Filter<Infer<T>>,
-
options?: CountDocumentsOptions
+
options?: CountDocumentsOptions,
): Promise<number> {
return await collection.countDocuments(query, options);
}
/**
* Execute an aggregation pipeline
-
*
+
*
* @param collection - MongoDB collection
* @param pipeline - MongoDB aggregation pipeline
* @param options - Aggregate options (including session for transactions)
···
export async function aggregate<T extends Schema>(
collection: Collection<Infer<T>>,
pipeline: Document[],
-
options?: AggregateOptions
+
options?: AggregateOptions,
): Promise<Document[]> {
return await collection.aggregate(pipeline, options).toArray();
}
+90 -60
model/index.ts
···
import type { z } from "@zod/zod";
import type {
+
AggregateOptions,
+
BulkWriteOptions,
Collection,
+
CountDocumentsOptions,
CreateIndexesOptions,
+
DeleteOptions,
DeleteResult,
Document,
DropIndexesOptions,
Filter,
+
FindOneAndReplaceOptions,
+
FindOneAndUpdateOptions,
+
FindOptions,
IndexDescription,
IndexSpecification,
InsertManyResult,
+
InsertOneOptions,
InsertOneResult,
-
InsertOneOptions,
-
FindOptions,
-
UpdateOptions,
+
ListIndexesOptions,
+
ModifyResult,
ReplaceOptions,
-
FindOneAndUpdateOptions,
-
FindOneAndReplaceOptions,
-
DeleteOptions,
-
CountDocumentsOptions,
-
AggregateOptions,
-
ListIndexesOptions,
+
UpdateOptions,
UpdateResult,
WithId,
-
BulkWriteOptions,
-
ModifyResult,
} from "mongodb";
import type { ObjectId } from "mongodb";
import { getDb } from "../client/connection.ts";
-
import type { Schema, Infer, Input, Indexes, ModelDef } from "../types.ts";
+
import type { Indexes, Infer, Input, ModelDef, Schema } from "../types.ts";
import * as core from "./core.ts";
import * as indexes from "./indexes.ts";
import * as pagination from "./pagination.ts";
/**
* Model class for type-safe MongoDB operations
-
*
+
*
* Provides a clean API for CRUD operations, pagination, and index management
* with automatic Zod validation and TypeScript type safety.
-
*
+
*
* @example
* ```ts
* const userSchema = z.object({
* name: z.string(),
* email: z.string().email(),
* });
-
*
+
*
* const UserModel = new Model("users", userSchema);
* await UserModel.insertOne({ name: "Alice", email: "alice@example.com" });
* ```
···
this.schema = definition as T;
}
this.collection = getDb().collection<Infer<T>>(collectionName);
-
+
// Automatically create indexes if they were provided
if (this.indexes && this.indexes.length > 0) {
// Fire and forget - indexes will be created asynchronously
-
indexes.syncIndexes(this.collection, this.indexes)
+
indexes.syncIndexes(this.collection, this.indexes);
}
}
···
/**
* Insert a single document into the collection
-
*
+
*
* @param data - Document data to insert
* @param options - Insert options (including session for transactions)
* @returns Insert result with insertedId
*/
async insertOne(
data: Input<T>,
-
options?: InsertOneOptions
+
options?: InsertOneOptions,
): Promise<InsertOneResult<Infer<T>>> {
return await core.insertOne(this.collection, this.schema, data, options);
}
/**
* Insert multiple documents into the collection
-
*
+
*
* @param data - Array of document data to insert
* @param options - Insert options (including session for transactions)
* @returns Insert result with insertedIds
*/
async insertMany(
data: Input<T>[],
-
options?: BulkWriteOptions
+
options?: BulkWriteOptions,
): Promise<InsertManyResult<Infer<T>>> {
return await core.insertMany(this.collection, this.schema, data, options);
}
/**
* Find multiple documents matching the query
-
*
+
*
* @param query - MongoDB query filter
* @param options - Find options (including session for transactions)
* @returns Array of matching documents
*/
async find(
query: Filter<Infer<T>>,
-
options?: FindOptions
+
options?: FindOptions,
): Promise<(WithId<Infer<T>>)[]> {
return await core.find(this.collection, query, options);
}
/**
* Find a single document matching the query
-
*
+
*
* @param query - MongoDB query filter
* @param options - Find options (including session for transactions)
* @returns Matching document or null if not found
*/
async findOne(
query: Filter<Infer<T>>,
-
options?: FindOptions
+
options?: FindOptions,
): Promise<WithId<Infer<T>> | null> {
return await core.findOne(this.collection, query, options);
}
/**
* Find a document by its MongoDB ObjectId
-
*
+
*
* @param id - Document ID (string or ObjectId)
* @param options - Find options (including session for transactions)
* @returns Matching document or null if not found
*/
async findById(
id: string | ObjectId,
-
options?: FindOptions
+
options?: FindOptions,
): Promise<WithId<Infer<T>> | null> {
return await core.findById(this.collection, id, options);
}
/**
* Update multiple documents matching the query
-
*
+
*
* @param query - MongoDB query filter
* @param data - Partial data to update
* @param options - Update options (including session for transactions)
···
async update(
query: Filter<Infer<T>>,
data: Partial<z.infer<T>>,
-
options?: UpdateOptions
+
options?: UpdateOptions,
): Promise<UpdateResult<Infer<T>>> {
-
return await core.update(this.collection, this.schema, query, data, options);
+
return await core.update(
+
this.collection,
+
this.schema,
+
query,
+
data,
+
options,
+
);
}
/**
* Update a single document matching the query
-
*
+
*
* @param query - MongoDB query filter
* @param data - Partial data to update
* @param options - Update options (including session for transactions)
···
async updateOne(
query: Filter<Infer<T>>,
data: Partial<z.infer<T>>,
-
options?: UpdateOptions
+
options?: UpdateOptions,
): Promise<UpdateResult<Infer<T>>> {
-
return await core.updateOne(this.collection, this.schema, query, data, options);
+
return await core.updateOne(
+
this.collection,
+
this.schema,
+
query,
+
data,
+
options,
+
);
}
/**
* Find a single document and update it
-
*
+
*
* @param query - MongoDB query filter
* @param data - Partial data to update
* @param options - FindOneAndUpdate options (including upsert and returnDocument)
···
async findOneAndUpdate(
query: Filter<Infer<T>>,
data: Partial<z.infer<T>>,
-
options?: FindOneAndUpdateOptions
+
options?: FindOneAndUpdateOptions,
): Promise<ModifyResult<Infer<T>>> {
-
return await core.findOneAndUpdate(this.collection, this.schema, query, data, options);
+
return await core.findOneAndUpdate(
+
this.collection,
+
this.schema,
+
query,
+
data,
+
options,
+
);
}
/**
* Replace a single document matching the query
-
*
+
*
* @param query - MongoDB query filter
* @param data - Complete document data for replacement
* @param options - Replace options (including session for transactions)
···
async replaceOne(
query: Filter<Infer<T>>,
data: Input<T>,
-
options?: ReplaceOptions
+
options?: ReplaceOptions,
): Promise<UpdateResult<Infer<T>>> {
-
return await core.replaceOne(this.collection, this.schema, query, data, options);
+
return await core.replaceOne(
+
this.collection,
+
this.schema,
+
query,
+
data,
+
options,
+
);
}
/**
* Find a single document and replace it
-
*
+
*
* @param query - MongoDB query filter
* @param data - Complete document data for replacement
* @param options - FindOneAndReplace options (including upsert and returnDocument)
···
async findOneAndReplace(
query: Filter<Infer<T>>,
data: Input<T>,
-
options?: FindOneAndReplaceOptions
+
options?: FindOneAndReplaceOptions,
): Promise<ModifyResult<Infer<T>>> {
-
return await core.findOneAndReplace(this.collection, this.schema, query, data, options);
+
return await core.findOneAndReplace(
+
this.collection,
+
this.schema,
+
query,
+
data,
+
options,
+
);
}
/**
* Delete multiple documents matching the query
-
*
+
*
* @param query - MongoDB query filter
* @param options - Delete options (including session for transactions)
* @returns Delete result
*/
async delete(
query: Filter<Infer<T>>,
-
options?: DeleteOptions
+
options?: DeleteOptions,
): Promise<DeleteResult> {
return await core.deleteMany(this.collection, query, options);
}
/**
* Delete a single document matching the query
-
*
+
*
* @param query - MongoDB query filter
* @param options - Delete options (including session for transactions)
* @returns Delete result
*/
async deleteOne(
query: Filter<Infer<T>>,
-
options?: DeleteOptions
+
options?: DeleteOptions,
): Promise<DeleteResult> {
return await core.deleteOne(this.collection, query, options);
}
/**
* Count documents matching the query
-
*
+
*
* @param query - MongoDB query filter
* @param options - Count options (including session for transactions)
* @returns Number of matching documents
*/
async count(
query: Filter<Infer<T>>,
-
options?: CountDocumentsOptions
+
options?: CountDocumentsOptions,
): Promise<number> {
return await core.count(this.collection, query, options);
}
/**
* Execute an aggregation pipeline
-
*
+
*
* @param pipeline - MongoDB aggregation pipeline
* @param options - Aggregate options (including session for transactions)
* @returns Array of aggregation results
*/
async aggregate(
pipeline: Document[],
-
options?: AggregateOptions
+
options?: AggregateOptions,
): Promise<Document[]> {
return await core.aggregate(this.collection, pipeline, options);
}
···
/**
* Find documents with pagination support
-
*
+
*
* @param query - MongoDB query filter
* @param options - Pagination options (skip, limit, sort)
* @returns Array of matching documents
···
/**
* Create a single index on the collection
-
*
+
*
* @param keys - Index specification (e.g., { email: 1 } or { name: "text" })
* @param options - Index creation options (unique, sparse, expireAfterSeconds, etc.)
* @returns The name of the created index
···
/**
* Create multiple indexes on the collection
-
*
+
*
* @param indexes - Array of index descriptions
* @param options - Index creation options
* @returns Array of index names created
···
/**
* Drop a single index from the collection
-
*
+
*
* @param index - Index name or specification
* @param options - Drop index options
*/
···
/**
* Drop all indexes from the collection (except _id index)
-
*
+
*
* @param options - Drop index options
*/
async dropIndexes(options?: DropIndexesOptions): Promise<void> {
···
/**
* List all indexes on the collection
-
*
+
*
* @param options - List indexes options
* @returns Array of index information
*/
···
/**
* Get index information by name
-
*
+
*
* @param indexName - Name of the index
* @returns Index description or null if not found
*/
···
/**
* Check if an index exists
-
*
+
*
* @param indexName - Name of the index
* @returns True if index exists, false otherwise
*/
···
/**
* Synchronize indexes - create indexes if they don't exist, update if they differ
-
*
+
*
* This is useful for ensuring indexes match your schema definition
-
*
+
*
* @param indexes - Array of index descriptions to synchronize
* @param options - Options for index creation
* @returns Array of index names that were created
+15 -15
model/indexes.ts
···
IndexSpecification,
ListIndexesOptions,
} from "mongodb";
-
import type { Schema, Infer } from "../types.ts";
+
import type { Infer, Schema } from "../types.ts";
/**
* Index management operations for the Model class
-
*
+
*
* This module contains all index-related operations including creation,
* deletion, listing, and synchronization of indexes.
*/
/**
* Create a single index on the collection
-
*
+
*
* @param collection - MongoDB collection
* @param keys - Index specification (e.g., { email: 1 } or { name: "text" })
* @param options - Index creation options (unique, sparse, expireAfterSeconds, etc.)
···
/**
* Create multiple indexes on the collection
-
*
+
*
* @param collection - MongoDB collection
* @param indexes - Array of index descriptions
* @param options - Index creation options
···
/**
* Drop a single index from the collection
-
*
+
*
* @param collection - MongoDB collection
* @param index - Index name or specification
* @param options - Drop index options
···
/**
* Drop all indexes from the collection (except _id index)
-
*
+
*
* @param collection - MongoDB collection
* @param options - Drop index options
*/
export async function dropIndexes<T extends Schema>(
collection: Collection<Infer<T>>,
-
options?: DropIndexesOptions
+
options?: DropIndexesOptions,
): Promise<void> {
await collection.dropIndexes(options);
}
/**
* List all indexes on the collection
-
*
+
*
* @param collection - MongoDB collection
* @param options - List indexes options
* @returns Array of index information
···
/**
* Get index information by name
-
*
+
*
* @param collection - MongoDB collection
* @param indexName - Name of the index
* @returns Index description or null if not found
*/
export async function getIndex<T extends Schema>(
collection: Collection<Infer<T>>,
-
indexName: string
+
indexName: string,
): Promise<IndexDescription | null> {
const indexes = await listIndexes(collection);
return indexes.find((idx) => idx.name === indexName) || null;
···
/**
* Check if an index exists
-
*
+
*
* @param collection - MongoDB collection
* @param indexName - Name of the index
* @returns True if index exists, false otherwise
*/
export async function indexExists<T extends Schema>(
collection: Collection<Infer<T>>,
-
indexName: string
+
indexName: string,
): Promise<boolean> {
const index = await getIndex(collection, indexName);
return index !== null;
···
/**
* Synchronize indexes - create indexes if they don't exist, update if they differ
-
*
+
*
* This is useful for ensuring indexes match your schema definition
-
*
+
*
* @param collection - MongoDB collection
* @param indexes - Array of index descriptions to synchronize
* @param options - Options for index creation
···
/**
* Generate index name from key specification
-
*
+
*
* @param keys - Index specification
* @returns Generated index name
*/
+6 -11
model/pagination.ts
···
-
import type {
-
Collection,
-
Document,
-
Filter,
-
WithId,
-
} from "mongodb";
-
import type { Schema, Infer } from "../types.ts";
+
import type { Collection, Document, Filter, WithId } from "mongodb";
+
import type { Infer, Schema } from "../types.ts";
/**
* Pagination operations for the Model class
-
*
+
*
* This module contains pagination-related functionality for finding documents
* with skip, limit, and sort options.
*/
/**
* Find documents with pagination support
-
*
+
*
* @param collection - MongoDB collection
* @param query - MongoDB query filter
* @param options - Pagination options (skip, limit, sort)
* @returns Array of matching documents
-
*
+
*
* @example
* ```ts
-
* const users = await findPaginated(collection,
+
* const users = await findPaginated(collection,
* { age: { $gte: 18 } },
* { skip: 0, limit: 10, sort: { createdAt: -1 } }
* );
+86 -51
model/validation.ts
···
import type { z } from "@zod/zod";
-
import type { Schema, Infer, Input } from "../types.ts";
-
import { ValidationError, AsyncValidationError } from "../errors.ts";
-
import type { Document, UpdateFilter, Filter } from "mongodb";
+
import type { Infer, Input, Schema } from "../types.ts";
+
import { AsyncValidationError, ValidationError } from "../errors.ts";
+
import type { Document, Filter, UpdateFilter } from "mongodb";
+
+
// Cache frequently reused schema transformations to avoid repeated allocations
+
const partialSchemaCache = new WeakMap<Schema, z.ZodTypeAny>();
+
const defaultsCache = new WeakMap<Schema, Record<string, unknown>>();
+
const updateOperators = [
+
"$set",
+
"$unset",
+
"$inc",
+
"$mul",
+
"$rename",
+
"$min",
+
"$max",
+
"$currentDate",
+
"$push",
+
"$pull",
+
"$addToSet",
+
"$pop",
+
"$bit",
+
"$setOnInsert",
+
];
+
+
function getPartialSchema(schema: Schema): z.ZodTypeAny {
+
const cached = partialSchemaCache.get(schema);
+
if (cached) return cached;
+
const partial = schema.partial();
+
partialSchemaCache.set(schema, partial);
+
return partial;
+
}
/**
* Validate data for insert operations using Zod schema
-
*
+
*
* @param schema - Zod schema to validate against
* @param data - Data to validate
* @returns Validated and typed data
···
*/
export function parse<T extends Schema>(schema: T, data: Input<T>): Infer<T> {
const result = schema.safeParse(data);
-
+
// Check for async validation
if (result instanceof Promise) {
throw new AsyncValidationError();
}
-
+
if (!result.success) {
throw new ValidationError(result.error.issues, "insert");
}
···
/**
* Validate partial data for update operations using Zod schema
-
*
+
*
* Important: This function only validates the fields that are provided in the data object.
* Unlike parse(), this function does NOT apply defaults for missing fields because
* in an update context, missing fields should remain unchanged in the database.
-
*
+
*
* @param schema - Zod schema to validate against
* @param data - Partial data to validate
* @returns Validated and typed partial data (only fields present in input)
···
schema: T,
data: Partial<z.infer<T>>,
): Partial<z.infer<T>> {
+
if (!data || Object.keys(data).length === 0) {
+
return {};
+
}
+
// Get the list of fields actually provided in the input
const inputKeys = Object.keys(data);
-
-
const result = schema.partial().safeParse(data);
-
+
+
const result = getPartialSchema(schema).safeParse(data);
+
// Check for async validation
if (result instanceof Promise) {
throw new AsyncValidationError();
}
-
+
if (!result.success) {
throw new ValidationError(result.error.issues, "update");
}
-
+
// Filter the result to only include fields that were in the input
// This prevents defaults from being applied to fields that weren't provided
const filtered: Record<string, unknown> = {};
for (const key of inputKeys) {
-
if (key in result.data) {
+
if (key in (result.data as Record<string, unknown>)) {
filtered[key] = (result.data as Record<string, unknown>)[key];
}
}
-
+
return filtered as Partial<z.infer<T>>;
}
/**
* Validate data for replace operations using Zod schema
-
*
+
*
* @param schema - Zod schema to validate against
* @param data - Data to validate
* @returns Validated and typed data
* @throws {ValidationError} If validation fails
* @throws {AsyncValidationError} If async validation is detected
*/
-
export function parseReplace<T extends Schema>(schema: T, data: Input<T>): Infer<T> {
+
export function parseReplace<T extends Schema>(
+
schema: T,
+
data: Input<T>,
+
): Infer<T> {
const result = schema.safeParse(data);
-
+
// Check for async validation
if (result instanceof Promise) {
throw new AsyncValidationError();
}
-
+
if (!result.success) {
throw new ValidationError(result.error.issues, "replace");
}
···
/**
* Extract default values from a Zod schema
* This parses an empty object through the schema to get all defaults applied
-
*
+
*
* @param schema - Zod schema to extract defaults from
* @returns Object containing all default values from the schema
*/
-
export function extractDefaults<T extends Schema>(schema: T): Partial<Infer<T>> {
+
export function extractDefaults<T extends Schema>(
+
schema: T,
+
): Partial<Infer<T>> {
+
const cached = defaultsCache.get(schema);
+
if (cached) {
+
return cached as Partial<Infer<T>>;
+
}
+
try {
// Make all fields optional, then parse empty object to trigger defaults
// This allows us to see which fields get default values
-
const partialSchema = schema.partial();
+
const partialSchema = getPartialSchema(schema);
const result = partialSchema.safeParse({});
-
+
if (result instanceof Promise) {
// Cannot extract defaults from async schemas
return {};
}
-
+
// If successful, the result contains all fields that have defaults
// Only include fields that were actually added (have values)
if (!result.success) {
return {};
}
-
+
// Filter to only include fields that got values from defaults
// (not undefined, which indicates no default)
const defaults: Record<string, unknown> = {};
const data = result.data as Record<string, unknown>;
-
+
for (const [key, value] of Object.entries(data)) {
if (value !== undefined) {
defaults[key] = value;
}
}
-
+
defaultsCache.set(schema, defaults as Partial<Infer<Schema>>);
return defaults as Partial<Infer<T>>;
} catch {
return {};
···
/**
* Get all field paths mentioned in an update filter object
* This includes fields in $set, $unset, $inc, $push, etc.
-
*
+
*
* @param update - MongoDB update filter
* @returns Set of field paths that are being modified
*/
function getModifiedFields(update: UpdateFilter<Document>): Set<string> {
const fields = new Set<string>();
-
-
// Operators that modify fields
-
const operators = [
-
'$set', '$unset', '$inc', '$mul', '$rename', '$min', '$max',
-
'$currentDate', '$push', '$pull', '$addToSet', '$pop', '$bit',
-
'$setOnInsert',
-
];
-
-
for (const op of operators) {
-
if (update[op] && typeof update[op] === 'object') {
+
+
for (const op of updateOperators) {
+
if (update[op] && typeof update[op] === "object") {
// Add all field names from this operator
for (const field of Object.keys(update[op] as Document)) {
fields.add(field);
}
}
}
-
+
return fields;
}
···
/**
* Apply schema defaults to an update operation using $setOnInsert
-
*
+
*
* This is used for upsert operations to ensure defaults are applied when
* a new document is created, but not when updating an existing document.
-
*
+
*
* For each default field:
* - If the field is NOT mentioned in any update operator ($set, $inc, etc.)
* - If the field is NOT fixed by an equality clause in the query filter
* - Add it to $setOnInsert so it's only applied on insert
-
*
+
*
* @param schema - Zod schema with defaults
* @param query - MongoDB query filter
* @param update - MongoDB update filter
···
export function applyDefaultsForUpsert<T extends Schema>(
schema: T,
query: Filter<Infer<T>>,
-
update: UpdateFilter<Infer<T>>
+
update: UpdateFilter<Infer<T>>,
): UpdateFilter<Infer<T>> {
// Extract defaults from schema
const defaults = extractDefaults(schema);
-
+
// If no defaults, return update unchanged
if (Object.keys(defaults).length === 0) {
return update;
}
-
+
// Get fields that are already being modified
const modifiedFields = getModifiedFields(update as UpdateFilter<Document>);
const filterEqualityFields = getEqualityFields(query as Filter<Document>);
-
+
// Build $setOnInsert with defaults for unmodified fields
const setOnInsert: Partial<Infer<T>> = {};
-
+
for (const [field, value] of Object.entries(defaults)) {
// Only add default if field is not already being modified or fixed by filter equality
if (!modifiedFields.has(field) && !filterEqualityFields.has(field)) {
setOnInsert[field as keyof Infer<T>] = value as Infer<T>[keyof Infer<T>];
}
}
-
+
// If there are defaults to add, merge them into $setOnInsert
if (Object.keys(setOnInsert).length > 0) {
return {
...update,
$setOnInsert: {
...(update.$setOnInsert || {}),
-
...setOnInsert
-
} as Partial<Infer<T>>
+
...setOnInsert,
+
} as Partial<Infer<T>>,
};
}
-
+
return update;
}
+42 -37
tests/connection_test.ts
···
import { assert, assertEquals, assertExists } from "@std/assert";
-
import { connect, disconnect, healthCheck, type ConnectOptions } from "../mod.ts";
+
import {
+
connect,
+
type ConnectOptions,
+
disconnect,
+
healthCheck,
+
} from "../mod.ts";
import { MongoMemoryServer } from "mongodb-memory-server-core";
let mongoServer: MongoMemoryServer | null = null;
···
async fn() {
const uri = await setupTestServer();
const connection = await connect(uri, "test_db");
-
+
assert(connection);
assert(connection.client);
assert(connection.db);
···
maxIdleTimeMS: 30000,
connectTimeoutMS: 5000,
};
-
+
const connection = await connect(uri, "test_db", options);
-
+
assert(connection);
assert(connection.client);
assert(connection.db);
-
+
// Verify connection is working
const adminDb = connection.db.admin();
const serverStatus = await adminDb.serverStatus();
···
name: "Connection: Singleton - should reuse existing connection",
async fn() {
const uri = await setupTestServer();
-
+
const connection1 = await connect(uri, "test_db");
const connection2 = await connect(uri, "test_db");
-
+
// Should return the same connection instance
assertEquals(connection1, connection2);
assertEquals(connection1.client, connection2.client);
···
name: "Connection: Disconnect - should disconnect and allow reconnection",
async fn() {
const uri = await setupTestServer();
-
+
const connection1 = await connect(uri, "test_db");
assert(connection1);
-
+
await disconnect();
-
+
// Should be able to reconnect
const connection2 = await connect(uri, "test_db");
assert(connection2);
-
+
// Should be a new connection instance
assert(connection1 !== connection2);
},
···
const options: ConnectOptions = {
maxPoolSize: 5,
};
-
+
const connection = await connect(uri, "test_db", options);
-
+
// Verify connection works with custom pool size
const collections = await connection.db.listCollections().toArray();
assert(Array.isArray(collections));
···
});
Deno.test({
-
name: "Connection: Multiple Databases - should handle different database names",
+
name:
+
"Connection: Multiple Databases - should handle different database names",
async fn() {
const uri = await setupTestServer();
-
+
// Connect to first database
const connection1 = await connect(uri, "db1");
assertEquals(connection1.db.databaseName, "db1");
-
+
// Disconnect first
await disconnect();
-
+
// Connect to second database
const connection2 = await connect(uri, "db2");
assertEquals(connection2.db.databaseName, "db2");
···
name: "Health Check: should return unhealthy when not connected",
async fn() {
const result = await healthCheck();
-
+
assertEquals(result.healthy, false);
assertEquals(result.connected, false);
assertExists(result.error);
···
async fn() {
const uri = await setupTestServer();
await connect(uri, "test_db");
-
+
const result = await healthCheck();
-
+
assertEquals(result.healthy, true);
assertEquals(result.connected, true);
assertExists(result.responseTimeMs);
···
async fn() {
const uri = await setupTestServer();
await connect(uri, "test_db");
-
+
const result = await healthCheck();
-
+
assertEquals(result.healthy, true);
assertExists(result.responseTimeMs);
// Response time should be reasonable (less than 1 second for in-memory MongoDB)
···
async fn() {
const uri = await setupTestServer();
await connect(uri, "test_db");
-
+
// Run health check multiple times
const results = await Promise.all([
healthCheck(),
healthCheck(),
healthCheck(),
]);
-
+
// All should be healthy
for (const result of results) {
assertEquals(result.healthy, true);
···
async fn() {
const uri = await setupTestServer();
await connect(uri, "test_db");
-
+
// First check should be healthy
let result = await healthCheck();
assertEquals(result.healthy, true);
-
+
// Disconnect
await disconnect();
-
+
// Second check should be unhealthy
result = await healthCheck();
assertEquals(result.healthy, false);
···
serverSelectionTimeoutMS: 5000,
connectTimeoutMS: 5000,
};
-
+
const connection = await connect(uri, "test_db", options);
-
+
assert(connection);
assert(connection.client);
assert(connection.db);
-
+
// Verify connection works with retry options
const collections = await connection.db.listCollections().toArray();
assert(Array.isArray(collections));
···
// Pooling
maxPoolSize: 10,
minPoolSize: 2,
-
+
// Retry logic
retryReads: true,
retryWrites: true,
-
+
// Timeouts
connectTimeoutMS: 10000,
socketTimeoutMS: 45000,
serverSelectionTimeoutMS: 10000,
-
+
// Resilience
maxIdleTimeMS: 30000,
heartbeatFrequencyMS: 10000,
};
-
+
const connection = await connect(uri, "test_db", options);
-
+
assert(connection);
-
+
// Verify connection is working
const adminDb = connection.db.admin();
const serverStatus = await adminDb.serverStatus();
···
sanitizeResources: false,
sanitizeOps: false,
});
-
+1 -8
tests/crud_test.ts
···
Deno.test.beforeAll(async () => {
await setupTestDb();
-
UserModel = createUserModel();
+
UserModel = createUserModel("users_crud");
});
Deno.test.beforeEach(async () => {
···
Deno.test({
name: "CRUD: Insert - should insert a new user successfully",
async fn() {
-
const newUser: UserInsert = {
name: "Test User",
email: "test@example.com",
···
Deno.test({
name: "CRUD: Find - should find the inserted user",
async fn() {
-
// First insert a user for this test
const newUser: UserInsert = {
name: "Find Test User",
···
Deno.test({
name: "CRUD: Update - should update user data",
async fn() {
-
// Insert a user for this test
const newUser: UserInsert = {
name: "Update Test User",
···
Deno.test({
name: "CRUD: Delete - should delete user successfully",
async fn() {
-
// Insert a user for this test
const newUser: UserInsert = {
name: "Delete Test User",
···
Deno.test({
name: "CRUD: Find Multiple - should find multiple users",
async fn() {
-
// Insert multiple users
const users: UserInsert[] = [
{ name: "User 1", email: "user1@example.com", age: 20 },
···
sanitizeResources: false,
sanitizeOps: false,
});
-
-
+33 -31
tests/defaults_test.ts
···
import { assertEquals, assertExists } from "@std/assert";
import { z } from "@zod/zod";
-
import { connect, disconnect, Model } from "../mod.ts";
+
import { Model } from "../mod.ts";
import { applyDefaultsForUpsert } from "../model/validation.ts";
-
import { MongoMemoryServer } from "mongodb-memory-server-core";
+
import { setupTestDb, teardownTestDb } from "./utils.ts";
/**
* Test suite for default value handling in different operation types
-
*
+
*
* This tests the three main cases:
* 1. Plain inserts - defaults applied directly
* 2. Updates without upsert - defaults NOT applied
···
});
let ProductModel: Model<typeof productSchema>;
-
let mongoServer: MongoMemoryServer;
Deno.test.beforeAll(async () => {
-
mongoServer = await MongoMemoryServer.create();
-
const uri = mongoServer.getUri();
-
await connect(uri, "test_defaults_db");
+
await setupTestDb();
ProductModel = new Model("test_products_defaults", productSchema);
});
···
Deno.test.afterAll(async () => {
await ProductModel.delete({});
-
await disconnect();
-
await mongoServer.stop();
+
await teardownTestDb();
});
Deno.test({
···
// Verify defaults were applied
const product = await ProductModel.findById(result.insertedId);
assertExists(product);
-
+
assertEquals(product.name, "Widget");
assertEquals(product.price, 29.99);
assertEquals(product.category, "general"); // default
···
createdAt: new Date("2023-01-01"),
tags: ["test"],
});
-
+
assertExists(insertResult.insertedId);
// Now update it - defaults should NOT be applied
await ProductModel.updateOne(
{ _id: insertResult.insertedId },
-
{ price: 24.99 }
+
{ price: 24.99 },
// No upsert flag
);
const updated = await ProductModel.findById(insertResult.insertedId);
assertExists(updated);
-
+
assertEquals(updated.price, 24.99); // updated
assertEquals(updated.category, "electronics"); // unchanged
assertEquals(updated.inStock, false); // unchanged
···
});
Deno.test({
-
name: "Defaults: Case 3 - Upsert that creates applies defaults via $setOnInsert",
+
name:
+
"Defaults: Case 3 - Upsert that creates applies defaults via $setOnInsert",
async fn() {
// Upsert with a query that won't match - will create new document
const result = await ProductModel.updateOne(
{ name: "NonExistent" },
{ price: 39.99 },
-
{ upsert: true }
+
{ upsert: true },
);
assertEquals(result.upsertedCount, 1);
···
// Verify the created document has defaults applied
const product = await ProductModel.findOne({ name: "NonExistent" });
assertExists(product);
-
+
assertEquals(product.price, 39.99); // from $set
assertEquals(product.name, "NonExistent"); // from query
assertEquals(product.category, "general"); // default via $setOnInsert
···
const result = await ProductModel.updateOne(
{ name: "ExistingProduct" },
{ price: 44.99 },
-
{ upsert: true }
+
{ upsert: true },
);
assertEquals(result.matchedCount, 1);
···
// Verify defaults were NOT applied (existing values preserved)
const product = await ProductModel.findOne({ name: "ExistingProduct" });
assertExists(product);
-
+
assertEquals(product.price, 44.99); // updated via $set
assertEquals(product.category, "premium"); // preserved (not overwritten with default)
assertEquals(product.inStock, false); // preserved
···
name: "Replaced",
price: 15.0,
// category, inStock, createdAt, tags not provided - defaults should apply
-
}
+
},
);
const product = await ProductModel.findById(insertResult.insertedId);
assertExists(product);
-
+
assertEquals(product.name, "Replaced");
assertEquals(product.price, 15.0);
assertEquals(product.category, "general"); // default applied
···
price: 99.99,
// Missing optional fields - defaults should apply
},
-
{ upsert: true }
+
{ upsert: true },
);
assertEquals(result.upsertedCount, 1);
···
const product = await ProductModel.findOne({ name: "NewViaReplace" });
assertExists(product);
-
+
assertEquals(product.name, "NewViaReplace");
assertEquals(product.price, 99.99);
assertEquals(product.category, "general"); // default
···
category: "custom", // Explicitly setting a field that has a default
// inStock not set - should get default
},
-
{ upsert: true }
+
{ upsert: true },
);
assertEquals(result.upsertedCount, 1);
const product = await ProductModel.findOne({ name: "CustomDefaults" });
assertExists(product);
-
+
assertEquals(product.name, "CustomDefaults"); // from query
assertEquals(product.price, 25.0); // from $set
assertEquals(product.category, "custom"); // from $set (NOT default)
···
assertExists(product.createdAt);
assertEquals(product.inStock, true);
assertEquals(product.tags, []);
-
+
if (product.name === "Bulk2") {
assertEquals(product.category, "special");
} else {
···
});
Deno.test({
-
name: "Defaults: applyDefaultsForUpsert preserves existing $setOnInsert values",
+
name:
+
"Defaults: applyDefaultsForUpsert preserves existing $setOnInsert values",
fn() {
const schema = z.object({
name: z.string(),
···
});
Deno.test({
-
name: "Defaults: applyDefaultsForUpsert keeps query equality fields untouched",
+
name:
+
"Defaults: applyDefaultsForUpsert keeps query equality fields untouched",
fn() {
const schema = z.object({
status: z.string().default("pending"),
···
});
Deno.test({
-
name: "Defaults: findOneAndUpdate with upsert preserves query equality fields",
+
name:
+
"Defaults: findOneAndUpdate with upsert preserves query equality fields",
async fn() {
await ProductModel.findOneAndUpdate(
{ name: "FindOneUpsert", category: "special" },
{ price: 12.5 },
-
{ upsert: true }
+
{ upsert: true },
);
const product = await ProductModel.findOne({ name: "FindOneUpsert" });
···
name: "FindOneReplaceUpsert",
price: 77.0,
},
-
{ upsert: true }
+
{ upsert: true },
);
assertExists(result.lastErrorObject?.upserted);
-
const product = await ProductModel.findOne({ name: "FindOneReplaceUpsert" });
+
const product = await ProductModel.findOne({
+
name: "FindOneReplaceUpsert",
+
});
assertExists(product);
assertEquals(product.name, "FindOneReplaceUpsert");
+53 -38
tests/errors_test.ts
···
import { assert, assertEquals, assertExists, assertRejects } from "@std/assert";
import {
connect,
+
ConnectionError,
disconnect,
Model,
ValidationError,
-
ConnectionError,
} from "../mod.ts";
import { z } from "@zod/zod";
import { MongoMemoryServer } from "mongodb-memory-server-core";
···
async fn() {
const uri = await setupTestServer();
await connect(uri, "test_db");
-
+
const UserModel = new Model("users", userSchema);
-
+
await assertRejects(
async () => {
await UserModel.insertOne({ name: "", email: "invalid" });
},
ValidationError,
-
"Validation failed on insert"
+
"Validation failed on insert",
);
},
sanitizeResources: false,
···
async fn() {
const uri = await setupTestServer();
await connect(uri, "test_db");
-
+
const UserModel = new Model("users", userSchema);
-
+
try {
await UserModel.insertOne({ name: "", email: "invalid" });
throw new Error("Should have thrown ValidationError");
···
assertEquals(error.operation, "insert");
assertExists(error.issues);
assert(error.issues.length > 0);
-
+
// Check field errors
const fieldErrors = error.getFieldErrors();
assertExists(fieldErrors.name);
···
async fn() {
const uri = await setupTestServer();
await connect(uri, "test_db");
-
+
const UserModel = new Model("users", userSchema);
-
+
await assertRejects(
async () => {
await UserModel.updateOne({ name: "test" }, { email: "invalid-email" });
},
ValidationError,
-
"Validation failed on update"
+
"Validation failed on update",
);
},
sanitizeResources: false,
···
async fn() {
const uri = await setupTestServer();
await connect(uri, "test_db");
-
+
const UserModel = new Model("users", userSchema);
-
+
// First insert a valid document
await UserModel.insertOne({ name: "Test", email: "test@example.com" });
-
+
await assertRejects(
async () => {
-
await UserModel.replaceOne({ name: "Test" }, { name: "", email: "invalid" });
+
await UserModel.replaceOne({ name: "Test" }, {
+
name: "",
+
email: "invalid",
+
});
},
ValidationError,
-
"Validation failed on replace"
+
"Validation failed on replace",
);
},
sanitizeResources: false,
···
async fn() {
const uri = await setupTestServer();
await connect(uri, "test_db");
-
+
const UserModel = new Model("users", userSchema);
-
+
try {
await UserModel.updateOne({ name: "test" }, { age: -5 });
throw new Error("Should have thrown ValidationError");
} catch (error) {
assert(error instanceof ValidationError);
assertEquals(error.operation, "update");
-
+
const fieldErrors = error.getFieldErrors();
assertExists(fieldErrors.age);
}
···
async fn() {
await assertRejects(
async () => {
-
await connect("mongodb://invalid-host-that-does-not-exist:27017", "test_db", {
-
serverSelectionTimeoutMS: 1000, // 1 second timeout
-
connectTimeoutMS: 1000,
-
});
+
await connect(
+
"mongodb://invalid-host-that-does-not-exist:27017",
+
"test_db",
+
{
+
serverSelectionTimeoutMS: 1000, // 1 second timeout
+
connectTimeoutMS: 1000,
+
},
+
);
},
ConnectionError,
-
"Failed to connect to MongoDB"
+
"Failed to connect to MongoDB",
);
},
sanitizeResources: false,
···
name: "Errors: ConnectionError - should include URI in error",
async fn() {
try {
-
await connect("mongodb://invalid-host-that-does-not-exist:27017", "test_db", {
-
serverSelectionTimeoutMS: 1000, // 1 second timeout
-
connectTimeoutMS: 1000,
-
});
+
await connect(
+
"mongodb://invalid-host-that-does-not-exist:27017",
+
"test_db",
+
{
+
serverSelectionTimeoutMS: 1000, // 1 second timeout
+
connectTimeoutMS: 1000,
+
},
+
);
throw new Error("Should have thrown ConnectionError");
} catch (error) {
assert(error instanceof ConnectionError);
-
assertEquals(error.uri, "mongodb://invalid-host-that-does-not-exist:27017");
+
assertEquals(
+
error.uri,
+
"mongodb://invalid-host-that-does-not-exist:27017",
+
);
}
},
sanitizeResources: false,
···
});
Deno.test({
-
name: "Errors: ConnectionError - should throw when getDb called without connection",
+
name:
+
"Errors: ConnectionError - should throw when getDb called without connection",
async fn() {
// Make sure not connected
await disconnect();
-
+
const { getDb } = await import("../client/connection.ts");
-
+
try {
getDb();
throw new Error("Should have thrown ConnectionError");
···
async fn() {
const uri = await setupTestServer();
await connect(uri, "test_db");
-
+
const UserModel = new Model("users", userSchema);
-
+
try {
await UserModel.insertOne({
name: "",
···
throw new Error("Should have thrown ValidationError");
} catch (error) {
assert(error instanceof ValidationError);
-
+
const fieldErrors = error.getFieldErrors();
-
+
// Each field should have its own errors
assert(Array.isArray(fieldErrors.name));
assert(Array.isArray(fieldErrors.email));
assert(Array.isArray(fieldErrors.age));
-
+
// Verify error messages are present
assert(fieldErrors.name.length > 0);
assert(fieldErrors.email.length > 0);
···
async fn() {
const uri = await setupTestServer();
await connect(uri, "test_db");
-
+
const UserModel = new Model("users", userSchema);
-
+
try {
await UserModel.insertOne({ name: "", email: "invalid" });
} catch (error) {
+1 -4
tests/features_test.ts
···
Deno.test.beforeAll(async () => {
await setupTestDb();
-
UserModel = createUserModel();
+
UserModel = createUserModel("users_features");
});
Deno.test.beforeEach(async () => {
···
Deno.test({
name: "Features: Default Values - should handle default createdAt",
async fn() {
-
const newUser: UserInsert = {
name: "Default Test User",
email: "default@example.com",
···
sanitizeResources: false,
sanitizeOps: false,
});
-
-
+1 -2
tests/index_test.ts
···
Deno.test.beforeAll(async () => {
await setupTestDb();
-
UserModel = createUserModel();
+
UserModel = createUserModel("users_index");
});
Deno.test.beforeEach(async () => {
···
sanitizeResources: false,
sanitizeOps: false,
});
-
+71 -63
tests/transactions_test.ts
···
import {
connect,
disconnect,
+
endSession,
Model,
-
withTransaction,
startSession,
-
endSession,
+
withTransaction,
} from "../mod.ts";
import { z } from "@zod/zod";
import { MongoMemoryReplSet } from "mongodb-memory-server-core";
···
async function setupTestReplSet() {
if (!replSet) {
replSet = await MongoMemoryReplSet.create({
-
replSet: {
-
count: 3,
-
storageEngine: 'wiredTiger' // Required for transactions
+
replSet: {
+
count: 1,
+
storageEngine: "wiredTiger", // Required for transactions
},
});
}
···
async fn() {
const uri = await setupTestReplSet();
await connect(uri, "test_db");
-
+
const UserModel = new Model("users", userSchema);
const OrderModel = new Model("orders", orderSchema);
-
+
const result = await withTransaction(async (session) => {
const user = await UserModel.insertOne(
{ name: "Alice", email: "alice@example.com", balance: 100 },
-
{ session }
+
{ session },
);
-
+
const order = await OrderModel.insertOne(
{ userId: user.insertedId.toString(), amount: 50 },
-
{ session }
+
{ session },
);
-
+
return { userId: user.insertedId, orderId: order.insertedId };
});
-
+
assertExists(result.userId);
assertExists(result.orderId);
-
+
// Verify data was committed
const users = await UserModel.find({});
const orders = await OrderModel.find({});
···
async fn() {
const uri = await setupTestReplSet();
await connect(uri, "test_db");
-
+
const UserModel = new Model("users", userSchema);
-
+
await assertRejects(
async () => {
await withTransaction(async (session) => {
await UserModel.insertOne(
{ name: "Bob", email: "bob@example.com" },
-
{ session }
+
{ session },
);
-
+
// This will fail and abort the transaction
throw new Error("Simulated error");
});
},
Error,
-
"Simulated error"
+
"Simulated error",
);
-
+
// Verify no data was committed
const users = await UserModel.find({});
assertEquals(users.length, 0);
···
async fn() {
const uri = await setupTestReplSet();
await connect(uri, "test_db");
-
+
const UserModel = new Model("users", userSchema);
-
+
const result = await withTransaction(async (session) => {
const users = [];
-
+
for (let i = 0; i < 5; i++) {
const user = await UserModel.insertOne(
{ name: `User${i}`, email: `user${i}@example.com` },
-
{ session }
+
{ session },
);
users.push(user.insertedId);
}
-
+
return users;
});
-
+
assertEquals(result.length, 5);
-
+
// Verify all users were created
const users = await UserModel.find({});
assertEquals(users.length, 5);
···
});
Deno.test({
-
name: "Transactions: withTransaction - should support read and write operations",
+
name:
+
"Transactions: withTransaction - should support read and write operations",
async fn() {
const uri = await setupTestReplSet();
await connect(uri, "test_db");
-
+
const UserModel = new Model("users", userSchema);
-
+
// Insert initial user
const initialUser = await UserModel.insertOne({
name: "Charlie",
email: "charlie@example.com",
balance: 100,
});
-
+
const result = await withTransaction(async (session) => {
// Read
-
const user = await UserModel.findById(initialUser.insertedId, { session });
+
const user = await UserModel.findById(initialUser.insertedId, {
+
session,
+
});
assertExists(user);
-
+
// Update
await UserModel.updateOne(
{ _id: initialUser.insertedId },
{ balance: 150 },
-
{ session }
+
{ session },
);
-
+
// Read again
-
const updatedUser = await UserModel.findById(initialUser.insertedId, { session });
-
+
const updatedUser = await UserModel.findById(initialUser.insertedId, {
+
session,
+
});
+
return updatedUser?.balance;
});
-
+
assertEquals(result, 150);
},
sanitizeResources: false,
···
async fn() {
const uri = await setupTestReplSet();
await connect(uri, "test_db");
-
+
const UserModel = new Model("users", userSchema);
-
+
await assertRejects(
async () => {
await withTransaction(async (session) => {
// Valid insert
await UserModel.insertOne(
{ name: "Valid", email: "valid@example.com" },
-
{ session }
+
{ session },
);
-
+
// Invalid insert (will throw ValidationError)
await UserModel.insertOne(
{ name: "", email: "invalid" },
-
{ session }
+
{ session },
);
});
},
-
Error // ValidationError
+
Error, // ValidationError
);
-
+
// Transaction should have been aborted, no data should exist
const users = await UserModel.find({});
assertEquals(users.length, 0);
···
});
Deno.test({
-
name: "Transactions: Manual session - should work with manual session management",
+
name:
+
"Transactions: Manual session - should work with manual session management",
async fn() {
const uri = await setupTestReplSet();
await connect(uri, "test_db");
-
+
const UserModel = new Model("users", userSchema);
-
+
const session = startSession();
-
+
try {
await session.withTransaction(async () => {
await UserModel.insertOne(
{ name: "Dave", email: "dave@example.com" },
-
{ session }
+
{ session },
);
await UserModel.insertOne(
{ name: "Eve", email: "eve@example.com" },
-
{ session }
+
{ session },
);
});
} finally {
await endSession(session);
}
-
+
// Verify both users were created
const users = await UserModel.find({});
assertEquals(users.length, 2);
···
async fn() {
const uri = await setupTestReplSet();
await connect(uri, "test_db");
-
+
const UserModel = new Model("users", userSchema);
-
+
// Insert initial users
await UserModel.insertMany([
{ name: "User1", email: "user1@example.com" },
{ name: "User2", email: "user2@example.com" },
{ name: "User3", email: "user3@example.com" },
]);
-
+
await withTransaction(async (session) => {
// Delete one user
await UserModel.deleteOne({ name: "User1" }, { session });
-
+
// Delete multiple users
-
await UserModel.delete({ name: { $in: ["User2", "User3"] } }, { session });
+
await UserModel.delete({ name: { $in: ["User2", "User3"] } }, {
+
session,
+
});
});
-
+
// Verify all were deleted
const users = await UserModel.find({});
assertEquals(users.length, 0);
···
async fn() {
const uri = await setupTestReplSet();
await connect(uri, "test_db");
-
+
const UserModel = new Model("users", userSchema);
-
+
const result = await withTransaction(
async (session) => {
await UserModel.insertOne(
{ name: "Frank", email: "frank@example.com" },
-
{ session }
+
{ session },
);
return "success";
},
···
readPreference: "primary",
readConcern: { level: "snapshot" },
writeConcern: { w: "majority" },
-
}
+
},
);
-
+
assertEquals(result, "success");
-
+
const users = await UserModel.find({});
assertEquals(users.length, 1);
},
+35 -10
tests/utils.ts
···
let mongoServer: MongoMemoryServer | null = null;
let isSetup = false;
+
let setupRefCount = 0;
+
let activeDbName: string | null = null;
-
export async function setupTestDb() {
-
if (!isSetup) {
-
// Start MongoDB Memory Server
+
export async function setupTestDb(dbName = "test_db") {
+
setupRefCount++;
+
+
// If we're already connected, just share the same database
+
if (isSetup) {
+
if (activeDbName !== dbName) {
+
throw new Error(
+
`Test DB already initialized for ${activeDbName}, requested ${dbName}`,
+
);
+
}
+
return;
+
}
+
+
try {
mongoServer = await MongoMemoryServer.create();
const uri = mongoServer.getUri();
-
-
// Connect to the in-memory database
-
await connect(uri, "test_db");
+
+
await connect(uri, dbName);
+
activeDbName = dbName;
isSetup = true;
+
} catch (error) {
+
// Roll back refcount if setup failed so future attempts can retry
+
setupRefCount = Math.max(0, setupRefCount - 1);
+
throw error;
}
}
export async function teardownTestDb() {
-
if (isSetup) {
+
if (setupRefCount === 0) {
+
return;
+
}
+
+
setupRefCount = Math.max(0, setupRefCount - 1);
+
+
if (isSetup && setupRefCount === 0) {
await disconnect();
if (mongoServer) {
await mongoServer.stop();
mongoServer = null;
}
+
activeDbName = null;
isSetup = false;
}
}
-
export function createUserModel(): Model<typeof userSchema> {
-
return new Model("users", userSchema);
+
export function createUserModel(
+
collectionName = "users",
+
): Model<typeof userSchema> {
+
return new Model(collectionName, userSchema);
}
export async function cleanupCollection(model: Model<typeof userSchema>) {
await model.delete({});
}
-
+1 -8
tests/validation_test.ts
···
Deno.test.beforeAll(async () => {
await setupTestDb();
-
UserModel = createUserModel();
+
UserModel = createUserModel("users_validation");
});
Deno.test.beforeEach(async () => {
···
Deno.test({
name: "Validation: Schema - should validate user data on insert",
async fn() {
-
const invalidUser = {
name: "Invalid User",
email: "not-an-email", // Invalid email
···
Deno.test({
name: "Validation: Update - should reject invalid email in update",
async fn() {
-
// Insert a user for this test
const newUser: UserInsert = {
name: "Validation Test User",
···
Deno.test({
name: "Validation: Update - should reject negative age in update",
async fn() {
-
// Insert a user for this test
const newUser: UserInsert = {
name: "Age Validation Test User",
···
Deno.test({
name: "Validation: Update - should reject invalid name type in update",
async fn() {
-
// Insert a user for this test
const newUser: UserInsert = {
name: "Type Validation Test User",
···
Deno.test({
name: "Validation: Update - should accept valid partial updates",
async fn() {
-
// Insert a user for this test
const newUser: UserInsert = {
name: "Valid Update Test User",
···
sanitizeResources: false,
sanitizeOps: false,
});
-
-
+5 -6
types.ts
···
import type { z } from "@zod/zod";
-
import type { Document, ObjectId, IndexDescription } from "mongodb";
+
import type { Document, IndexDescription, ObjectId } from "mongodb";
/**
* Type alias for Zod schema objects
···
*/
export type Infer<T extends Schema> = z.infer<T> & Document;
-
/**
* Infer the model type from a Zod schema, including MongoDB Document and ObjectId
*/
export type InferModel<T extends Schema> = Infer<T> & {
-
_id?: ObjectId;
-
};
+
_id?: ObjectId;
+
};
/**
* Infer the input type for a Zod schema (handles defaults)
···
/**
* Complete definition of a model, including schema and indexes
-
*
+
*
* @example
* ```ts
* const userDef: ModelDef<typeof userSchema> = {
···
export type ModelDef<T extends Schema> = {
schema: T;
indexes?: Indexes;
-
};
+
};