Thin MongoDB ODM built for Standard Schema
mongodb
zod
deno
1import type { StandardSchemaV1 } from "@standard-schema/spec";
2import type {
3 Collection,
4 DeleteResult,
5 Document,
6 Filter,
7 InsertManyResult,
8 InsertOneResult,
9 OptionalUnlessRequiredId,
10 UpdateResult,
11 WithId,
12} from "mongodb";
13import { ObjectId } from "mongodb";
14import { getDb } from "./client.ts";
15
16// Type alias for cleaner code
17type Schema = StandardSchemaV1<unknown, Document>;
18type Infer<T extends Schema> = StandardSchemaV1.InferOutput<T>;
19type Input<T extends Schema> = StandardSchemaV1.InferInput<T>;
20
21// Helper function to make StandardSchemaV1 validation as simple as Zod's parse()
22function parse<T extends Schema>(schema: T, data: unknown): Infer<T> {
23 const result = schema["~standard"].validate(data);
24 if (result instanceof Promise) {
25 throw new Error("Async validation not supported");
26 }
27 if (result.issues) {
28 throw new Error(`Validation failed: ${JSON.stringify(result.issues)}`);
29 }
30 return result.value;
31}
32
33export class Model<T extends Schema> {
34 private collection: Collection<Infer<T>>;
35 private schema: T;
36
37 constructor(collectionName: string, schema: T) {
38 this.collection = getDb().collection<Infer<T> & Document>(collectionName);
39 this.schema = schema;
40 }
41
42 async insertOne(data: Input<T>): Promise<InsertOneResult<Infer<T>>> {
43 const validatedData = parse(this.schema, data);
44 return await this.collection.insertOne(
45 validatedData as OptionalUnlessRequiredId<Infer<T>>,
46 );
47 }
48
49 async insertMany(data: Input<T>[]): Promise<InsertManyResult<Infer<T>>> {
50 const validatedData = data.map((item) => parse(this.schema, item));
51 return await this.collection.insertMany(
52 validatedData as OptionalUnlessRequiredId<Infer<T>>[],
53 );
54 }
55
56 async find(query: Filter<Infer<T>>): Promise<(WithId<Infer<T>>)[]> {
57 return await this.collection.find(query).toArray();
58 }
59
60 async findOne(query: Filter<Infer<T>>): Promise<WithId<Infer<T>> | null> {
61 return await this.collection.findOne(query);
62 }
63
64 async findById(id: string | ObjectId): Promise<WithId<Infer<T>> | null> {
65 const objectId = typeof id === "string" ? new ObjectId(id) : id;
66 return await this.findOne({ _id: objectId } as Filter<Infer<T>>);
67 }
68
69 async update(
70 query: Filter<Infer<T>>,
71 data: Partial<Infer<T>>,
72 ): Promise<UpdateResult> {
73 return await this.collection.updateMany(query, { $set: data });
74 }
75
76 async updateOne(
77 query: Filter<Infer<T>>,
78 data: Partial<Infer<T>>,
79 ): Promise<UpdateResult> {
80 return await this.collection.updateOne(query, { $set: data });
81 }
82
83 async replaceOne(
84 query: Filter<Infer<T>>,
85 data: Input<T>,
86 ): Promise<UpdateResult> {
87 const validatedData = parse(this.schema, data);
88 return await this.collection.replaceOne(
89 query,
90 validatedData as OptionalUnlessRequiredId<Infer<T>>,
91 );
92 }
93
94 async delete(query: Filter<Infer<T>>): Promise<DeleteResult> {
95 return await this.collection.deleteMany(query);
96 }
97
98 async deleteOne(query: Filter<Infer<T>>): Promise<DeleteResult> {
99 return await this.collection.deleteOne(query);
100 }
101
102 async count(query: Filter<Infer<T>>): Promise<number> {
103 return await this.collection.countDocuments(query);
104 }
105
106 async aggregate(pipeline: Document[]): Promise<Document[]> {
107 return await this.collection.aggregate(pipeline).toArray();
108 }
109
110 // Pagination support for find
111 async findPaginated(
112 query: Filter<Infer<T>>,
113 options: { skip?: number; limit?: number; sort?: Document } = {},
114 ): Promise<(WithId<Infer<T>>)[]> {
115 return await this.collection
116 .find(query)
117 .skip(options.skip ?? 0)
118 .limit(options.limit ?? 10)
119 .sort(options.sort ?? {})
120 .toArray();
121 }
122}