···17 UpdateResult,
18 WithId,
19 BulkWriteOptions,
020} from "mongodb";
21import { ObjectId } from "mongodb";
22import type { Schema, Infer, Input } from "../types.ts";
23-import { parse, parsePartial, parseReplace } from "./validation.ts";
2425/**
26 * Core CRUD operations for the Model class
···125/**
126 * Update multiple documents matching the query
127 *
0000128 * @param collection - MongoDB collection
129 * @param schema - Zod schema for validation
130 * @param query - MongoDB query filter
131 * @param data - Partial data to update
132- * @param options - Update options (including session for transactions)
133 * @returns Update result
134 */
135export async function update<T extends Schema>(
···140 options?: UpdateOptions
141): Promise<UpdateResult<Infer<T>>> {
142 const validatedData = parsePartial(schema, data);
143- return await collection.updateMany(
144- query,
145- { $set: validatedData as Partial<Infer<T>> },
146- options
147- );
000148}
149150/**
151 * Update a single document matching the query
152 *
0000153 * @param collection - MongoDB collection
154 * @param schema - Zod schema for validation
155 * @param query - MongoDB query filter
156 * @param data - Partial data to update
157- * @param options - Update options (including session for transactions)
158 * @returns Update result
159 */
160export async function updateOne<T extends Schema>(
···165 options?: UpdateOptions
166): Promise<UpdateResult<Infer<T>>> {
167 const validatedData = parsePartial(schema, data);
168- return await collection.updateOne(
169- query,
170- { $set: validatedData as Partial<Infer<T>> },
171- options
172- );
000173}
174175/**
176 * Replace a single document matching the query
177 *
00000000178 * @param collection - MongoDB collection
179 * @param schema - Zod schema for validation
180 * @param query - MongoDB query filter
181 * @param data - Complete document data for replacement
182- * @param options - Replace options (including session for transactions)
183 * @returns Update result
184 */
185export async function replaceOne<T extends Schema>(
···189 data: Input<T>,
190 options?: ReplaceOptions
191): Promise<UpdateResult<Infer<T>>> {
00192 const validatedData = parseReplace(schema, data);
0193 // Remove _id from validatedData for replaceOne (it will use the query's _id)
194 const { _id, ...withoutId } = validatedData as Infer<T> & { _id?: unknown };
195 return await collection.replaceOne(
···17 UpdateResult,
18 WithId,
19 BulkWriteOptions,
20+ UpdateFilter,
21} from "mongodb";
22import { ObjectId } from "mongodb";
23import type { Schema, Infer, Input } from "../types.ts";
24+import { parse, parsePartial, parseReplace, applyDefaultsForUpsert } from "./validation.ts";
2526/**
27 * Core CRUD operations for the Model class
···126/**
127 * Update multiple documents matching the query
128 *
129+ * Case handling:
130+ * - If upsert: false (or undefined) → Normal update, no defaults applied
131+ * - If upsert: true → Defaults added to $setOnInsert for new document creation
132+ *
133 * @param collection - MongoDB collection
134 * @param schema - Zod schema for validation
135 * @param query - MongoDB query filter
136 * @param data - Partial data to update
137+ * @param options - Update options (including session for transactions and upsert flag)
138 * @returns Update result
139 */
140export async function update<T extends Schema>(
···145 options?: UpdateOptions
146): Promise<UpdateResult<Infer<T>>> {
147 const validatedData = parsePartial(schema, data);
148+ let updateDoc: UpdateFilter<Infer<T>> = { $set: validatedData as Partial<Infer<T>> };
149+150+ // If this is an upsert, apply defaults using $setOnInsert
151+ if (options?.upsert) {
152+ updateDoc = applyDefaultsForUpsert(schema, query, updateDoc);
153+ }
154+155+ return await collection.updateMany(query, updateDoc, options);
156}
157158/**
159 * Update a single document matching the query
160 *
161+ * Case handling:
162+ * - If upsert: false (or undefined) → Normal update, no defaults applied
163+ * - If upsert: true → Defaults added to $setOnInsert for new document creation
164+ *
165 * @param collection - MongoDB collection
166 * @param schema - Zod schema for validation
167 * @param query - MongoDB query filter
168 * @param data - Partial data to update
169+ * @param options - Update options (including session for transactions and upsert flag)
170 * @returns Update result
171 */
172export async function updateOne<T extends Schema>(
···177 options?: UpdateOptions
178): Promise<UpdateResult<Infer<T>>> {
179 const validatedData = parsePartial(schema, data);
180+ let updateDoc: UpdateFilter<Infer<T>> = { $set: validatedData as Partial<Infer<T>> };
181+182+ // If this is an upsert, apply defaults using $setOnInsert
183+ if (options?.upsert) {
184+ updateDoc = applyDefaultsForUpsert(schema, query, updateDoc);
185+ }
186+187+ return await collection.updateOne(query, updateDoc, options);
188}
189190/**
191 * Replace a single document matching the query
192 *
193+ * Case handling:
194+ * - If upsert: false (or undefined) → Normal replace on existing doc, no additional defaults
195+ * - If upsert: true → Defaults applied via parse() since we're passing a full document
196+ *
197+ * Note: For replace operations, defaults are automatically applied by the schema's
198+ * parse() function which treats missing fields as candidates for defaults. This works
199+ * for both regular replaces and upsert-creates since we're providing a full document.
200+ *
201 * @param collection - MongoDB collection
202 * @param schema - Zod schema for validation
203 * @param query - MongoDB query filter
204 * @param data - Complete document data for replacement
205+ * @param options - Replace options (including session for transactions and upsert flag)
206 * @returns Update result
207 */
208export async function replaceOne<T extends Schema>(
···212 data: Input<T>,
213 options?: ReplaceOptions
214): Promise<UpdateResult<Infer<T>>> {
215+ // parseReplace will apply all schema defaults to missing fields
216+ // This works correctly for both regular replaces and upsert-created documents
217 const validatedData = parseReplace(schema, data);
218+219 // Remove _id from validatedData for replaceOne (it will use the query's _id)
220 const { _id, ...withoutId } = validatedData as Infer<T> & { _id?: unknown };
221 return await collection.replaceOne(
+192-2
model/validation.ts
···1import type { z } from "@zod/zod";
2import type { Schema, Infer, Input } from "../types.ts";
3import { ValidationError, AsyncValidationError } from "../errors.ts";
045/**
6 * Validate data for insert operations using Zod schema
···28/**
29 * Validate partial data for update operations using Zod schema
30 *
000031 * @param schema - Zod schema to validate against
32 * @param data - Partial data to validate
33- * @returns Validated and typed partial data
34 * @throws {ValidationError} If validation fails
35 * @throws {AsyncValidationError} If async validation is detected
36 */
···38 schema: T,
39 data: Partial<z.infer<T>>,
40): Partial<z.infer<T>> {
00041 const result = schema.partial().safeParse(data);
4243 // Check for async validation
···48 if (!result.success) {
49 throw new ValidationError(result.error.issues, "update");
50 }
51- return result.data as Partial<z.infer<T>>;
000000000052}
5354/**
···73 }
74 return result.data as Infer<T>;
75}
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···1import type { z } from "@zod/zod";
2import type { Schema, Infer, Input } from "../types.ts";
3import { ValidationError, AsyncValidationError } from "../errors.ts";
4+import type { Document, UpdateFilter, Filter } from "mongodb";
56/**
7 * Validate data for insert operations using Zod schema
···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)
39 * @throws {ValidationError} If validation fails
40 * @throws {AsyncValidationError} If async validation is detected
41 */
···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);
5051 // Check for async validation
···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}
7172/**
···91 }
92 return result.data as Infer<T>;
93}
94+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 {};
134+ }
135+}
136+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+166+/**
167+ * Get field paths that are fixed by equality clauses in a query filter.
168+ * Only equality-style predicates become part of the inserted document during upsert.
169+ */
170+function getEqualityFields(filter: Filter<Document>): Set<string> {
171+ const fields = new Set<string>();
172+173+ const collect = (node: Record<string, unknown>) => {
174+ for (const [key, value] of Object.entries(node)) {
175+ if (key.startsWith("$")) {
176+ if (Array.isArray(value)) {
177+ for (const item of value) {
178+ if (item && typeof item === "object" && !Array.isArray(item)) {
179+ collect(item as Record<string, unknown>);
180+ }
181+ }
182+ } else if (value && typeof value === "object") {
183+ collect(value as Record<string, unknown>);
184+ }
185+ continue;
186+ }
187+188+ if (value && typeof value === "object" && !Array.isArray(value)) {
189+ const objectValue = value as Record<string, unknown>;
190+ const keys = Object.keys(objectValue);
191+ const hasOperator = keys.some((k) => k.startsWith("$"));
192+193+ if (hasOperator) {
194+ if (Object.prototype.hasOwnProperty.call(objectValue, "$eq")) {
195+ fields.add(key);
196+ }
197+ } else {
198+ fields.add(key);
199+ }
200+ } else {
201+ fields.add(key);
202+ }
203+ }
204+ };
205+206+ collect(filter as Record<string, unknown>);
207+ return fields;
208+}
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
224+ * @returns Modified update filter with defaults in $setOnInsert
225+ */
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+}