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
33// Helper function to validate partial update data
34// Uses schema.partial() if available (e.g., Zod)
35function parsePartial<T extends Schema>(
36 schema: T,
37 data: Partial<Infer<T>>,
38): Partial<Infer<T>> {
39 // Get partial schema if available
40 const partialSchema = (
41 typeof schema === "object" &&
42 schema !== null &&
43 "partial" in schema &&
44 typeof (schema as { partial?: () => unknown }).partial === "function"
45 )
46 ? (schema as { partial: () => T }).partial()
47 : schema;
48
49 const result = partialSchema["~standard"].validate(data);
50 if (result instanceof Promise) {
51 throw new Error("Async validation not supported");
52 }
53 if (result.issues) {
54 throw new Error(`Update validation failed: ${JSON.stringify(result.issues)}`);
55 }
56 return result.value as Partial<Infer<T>>;
57}
58
59export class Model<T extends Schema> {
60 private collection: Collection<Infer<T>>;
61 private schema: T;
62
63 constructor(collectionName: string, schema: T) {
64 this.collection = getDb().collection<Infer<T> & Document>(collectionName);
65 this.schema = schema;
66 }
67
68 async insertOne(data: Input<T>): Promise<InsertOneResult<Infer<T>>> {
69 const validatedData = parse(this.schema, data);
70 return await this.collection.insertOne(
71 validatedData as OptionalUnlessRequiredId<Infer<T>>,
72 );
73 }
74
75 async insertMany(data: Input<T>[]): Promise<InsertManyResult<Infer<T>>> {
76 const validatedData = data.map((item) => parse(this.schema, item));
77 return await this.collection.insertMany(
78 validatedData as OptionalUnlessRequiredId<Infer<T>>[],
79 );
80 }
81
82 async find(query: Filter<Infer<T>>): Promise<(WithId<Infer<T>>)[]> {
83 return await this.collection.find(query).toArray();
84 }
85
86 async findOne(query: Filter<Infer<T>>): Promise<WithId<Infer<T>> | null> {
87 return await this.collection.findOne(query);
88 }
89
90 async findById(id: string | ObjectId): Promise<WithId<Infer<T>> | null> {
91 const objectId = typeof id === "string" ? new ObjectId(id) : id;
92 return await this.findOne({ _id: objectId } as Filter<Infer<T>>);
93 }
94
95 async update(
96 query: Filter<Infer<T>>,
97 data: Partial<Infer<T>>,
98 ): Promise<UpdateResult> {
99 const validatedData = parsePartial(this.schema, data);
100 return await this.collection.updateMany(query, { $set: validatedData });
101 }
102
103 async updateOne(
104 query: Filter<Infer<T>>,
105 data: Partial<Infer<T>>,
106 ): Promise<UpdateResult> {
107 const validatedData = parsePartial(this.schema, data);
108 return await this.collection.updateOne(query, { $set: validatedData });
109 }
110
111 async replaceOne(
112 query: Filter<Infer<T>>,
113 data: Input<T>,
114 ): Promise<UpdateResult> {
115 const validatedData = parse(this.schema, data);
116 return await this.collection.replaceOne(
117 query,
118 validatedData as OptionalUnlessRequiredId<Infer<T>>,
119 );
120 }
121
122 async delete(query: Filter<Infer<T>>): Promise<DeleteResult> {
123 return await this.collection.deleteMany(query);
124 }
125
126 async deleteOne(query: Filter<Infer<T>>): Promise<DeleteResult> {
127 return await this.collection.deleteOne(query);
128 }
129
130 async count(query: Filter<Infer<T>>): Promise<number> {
131 return await this.collection.countDocuments(query);
132 }
133
134 async aggregate(pipeline: Document[]): Promise<Document[]> {
135 return await this.collection.aggregate(pipeline).toArray();
136 }
137
138 // Pagination support for find
139 async findPaginated(
140 query: Filter<Infer<T>>,
141 options: { skip?: number; limit?: number; sort?: Document } = {},
142 ): Promise<(WithId<Infer<T>>)[]> {
143 return await this.collection
144 .find(query)
145 .skip(options.skip ?? 0)
146 .limit(options.limit ?? 10)
147 .sort(options.sort ?? {})
148 .toArray();
149 }
150}