@zodmon/core 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,19 +1,170 @@
1
1
  // src/client/client.ts
2
2
  import { MongoClient } from "mongodb";
3
3
 
4
- // src/crud/find.ts
5
- import { z as z2 } from "zod";
4
+ // src/indexes/spec.ts
5
+ function toFieldIndexSpec(def) {
6
+ const direction = def.text ? "text" : def.descending ? -1 : 1;
7
+ const key = { [def.field]: direction };
8
+ const options = {};
9
+ if (def.unique) options["unique"] = true;
10
+ if (def.sparse) options["sparse"] = true;
11
+ if (def.expireAfter !== void 0) options["expireAfterSeconds"] = def.expireAfter;
12
+ if (def.partial) options["partialFilterExpression"] = def.partial;
13
+ return { key, options };
14
+ }
15
+ function toCompoundIndexSpec(def) {
16
+ const key = { ...def.fields };
17
+ const options = {};
18
+ if (def.options?.unique) options["unique"] = true;
19
+ if (def.options?.sparse) options["sparse"] = true;
20
+ if (def.options?.name) options["name"] = def.options.name;
21
+ if (def.options?.partial) options["partialFilterExpression"] = def.options.partial;
22
+ return { key, options };
23
+ }
24
+ function serializeIndexKey(key) {
25
+ return Object.entries(key).map(([field, dir]) => `${field}:${dir}`).join(",");
26
+ }
6
27
 
7
- // src/errors/not-found.ts
8
- var ZodmonNotFoundError = class extends Error {
9
- name = "ZodmonNotFoundError";
10
- /** The MongoDB collection name where the query found no results. */
11
- collection;
12
- constructor(collection2) {
13
- super(`Document not found in "${collection2}"`);
14
- this.collection = collection2;
28
+ // src/indexes/sync.ts
29
+ var COMPARABLE_OPTION_KEYS = [
30
+ "unique",
31
+ "sparse",
32
+ "expireAfterSeconds",
33
+ "partialFilterExpression"
34
+ ];
35
+ function extractComparableOptions(info) {
36
+ const result = {};
37
+ for (const key of COMPARABLE_OPTION_KEYS) {
38
+ if (info[key] !== void 0) {
39
+ result[key] = info[key];
40
+ }
15
41
  }
16
- };
42
+ return result;
43
+ }
44
+ function generateIndexName(key) {
45
+ return Object.entries(key).map(([field, dir]) => `${field}_${dir}`).join("_");
46
+ }
47
+ function sortKeys(obj) {
48
+ const sorted = {};
49
+ for (const key of Object.keys(obj).sort()) {
50
+ sorted[key] = obj[key];
51
+ }
52
+ return sorted;
53
+ }
54
+ function resolveSpecName(spec) {
55
+ const specName = spec.options["name"];
56
+ return typeof specName === "string" ? specName : generateIndexName(spec.key);
57
+ }
58
+ function resolveExistingName(info, key) {
59
+ const infoName = info["name"];
60
+ if (typeof infoName === "string") return infoName;
61
+ return generateIndexName(key);
62
+ }
63
+ function optionsMatch(a, b) {
64
+ return JSON.stringify(sortKeys(stripName(a))) === JSON.stringify(sortKeys(stripName(b)));
65
+ }
66
+ function stripName(obj) {
67
+ const { name: _, ...rest } = obj;
68
+ return rest;
69
+ }
70
+ async function processDesiredSpec(spec, existingByKey, native, dryRun, dropOrphaned, acc) {
71
+ const serialized = serializeIndexKey(spec.key);
72
+ const existing = existingByKey.get(serialized);
73
+ if (!existing) {
74
+ if (!dryRun) await native.createIndex(spec.key, spec.options);
75
+ acc.created.push(resolveSpecName(spec));
76
+ return;
77
+ }
78
+ acc.matchedKeys.add(serialized);
79
+ const existingName = resolveExistingName(existing, spec.key);
80
+ const existingOpts = extractComparableOptions(existing);
81
+ if (optionsMatch(existingOpts, spec.options)) {
82
+ acc.skipped.push(existingName);
83
+ return;
84
+ }
85
+ if (dropOrphaned) {
86
+ if (!dryRun) {
87
+ await native.dropIndex(existingName);
88
+ await native.createIndex(spec.key, spec.options);
89
+ }
90
+ acc.dropped.push(existingName);
91
+ acc.created.push(resolveSpecName(spec));
92
+ return;
93
+ }
94
+ acc.stale.push({
95
+ name: existingName,
96
+ key: spec.key,
97
+ existing: existingOpts,
98
+ desired: spec.options
99
+ });
100
+ }
101
+ async function processOrphanedIndexes(existingIndexes, desiredKeys, matchedKeys, native, dryRun, dropOrphaned, dropped) {
102
+ for (const idx of existingIndexes) {
103
+ const rawName = idx["name"];
104
+ const name = typeof rawName === "string" ? rawName : "";
105
+ if (name === "_id_") continue;
106
+ const serialized = serializeIndexKey(idx["key"]);
107
+ if (matchedKeys.has(serialized) || desiredKeys.has(serialized)) continue;
108
+ if (dropOrphaned) {
109
+ if (!dryRun) await native.dropIndex(name);
110
+ dropped.push(name);
111
+ }
112
+ }
113
+ }
114
+ async function listIndexesSafe(native) {
115
+ try {
116
+ return await native.listIndexes().toArray();
117
+ } catch (err) {
118
+ if (err instanceof Error && err.message.includes("ns does not exist")) {
119
+ return [];
120
+ }
121
+ throw err;
122
+ }
123
+ }
124
+ async function syncIndexes(handle, options) {
125
+ const { dryRun = false, dropOrphaned = false } = options ?? {};
126
+ const native = handle.native;
127
+ const def = handle.definition;
128
+ const desiredSpecs = [
129
+ ...def.fieldIndexes.map(toFieldIndexSpec),
130
+ ...def.compoundIndexes.map(toCompoundIndexSpec)
131
+ ];
132
+ const existingIndexes = await listIndexesSafe(native);
133
+ const existingByKey = /* @__PURE__ */ new Map();
134
+ for (const idx of existingIndexes) {
135
+ const serialized = serializeIndexKey(idx["key"]);
136
+ existingByKey.set(serialized, idx);
137
+ }
138
+ const acc = {
139
+ created: [],
140
+ dropped: [],
141
+ skipped: [],
142
+ stale: [],
143
+ matchedKeys: /* @__PURE__ */ new Set()
144
+ };
145
+ for (const spec of desiredSpecs) {
146
+ await processDesiredSpec(spec, existingByKey, native, dryRun, dropOrphaned, acc);
147
+ }
148
+ const desiredKeys = new Set(desiredSpecs.map((s) => serializeIndexKey(s.key)));
149
+ await processOrphanedIndexes(
150
+ existingIndexes,
151
+ desiredKeys,
152
+ acc.matchedKeys,
153
+ native,
154
+ dryRun,
155
+ dropOrphaned,
156
+ acc.dropped
157
+ );
158
+ return {
159
+ created: acc.created,
160
+ dropped: acc.dropped,
161
+ skipped: acc.skipped,
162
+ stale: acc.stale
163
+ };
164
+ }
165
+
166
+ // src/crud/delete.ts
167
+ import { z } from "zod";
17
168
 
18
169
  // src/errors/validation.ts
19
170
  var ZodmonValidationError = class extends Error {
@@ -33,8 +184,136 @@ var ZodmonValidationError = class extends Error {
33
184
  }
34
185
  };
35
186
 
187
+ // src/crud/delete.ts
188
+ async function deleteOne(handle, filter) {
189
+ return await handle.native.deleteOne(filter);
190
+ }
191
+ async function deleteMany(handle, filter) {
192
+ return await handle.native.deleteMany(filter);
193
+ }
194
+ async function findOneAndDelete(handle, filter, options) {
195
+ const result = await handle.native.findOneAndDelete(
196
+ // biome-ignore lint/suspicious/noExplicitAny: TypedFilter intersection type is not directly assignable to MongoDB's Filter
197
+ filter,
198
+ { includeResultMetadata: false }
199
+ );
200
+ if (!result) return null;
201
+ const mode = options?.validate !== void 0 ? options.validate : handle.definition.options.validation;
202
+ if (mode === false || mode === "passthrough") {
203
+ return result;
204
+ }
205
+ try {
206
+ return handle.definition.schema.parse(result);
207
+ } catch (err) {
208
+ if (err instanceof z.ZodError) {
209
+ throw new ZodmonValidationError(handle.definition.name, err);
210
+ }
211
+ throw err;
212
+ }
213
+ }
214
+
215
+ // src/crud/find.ts
216
+ import { z as z3 } from "zod";
217
+
218
+ // src/errors/not-found.ts
219
+ var ZodmonNotFoundError = class extends Error {
220
+ name = "ZodmonNotFoundError";
221
+ /** The MongoDB collection name where the query found no results. */
222
+ collection;
223
+ constructor(collection2) {
224
+ super(`Document not found in "${collection2}"`);
225
+ this.collection = collection2;
226
+ }
227
+ };
228
+
229
+ // src/indexes/warn.ts
230
+ var SKIP_OPERATORS = /* @__PURE__ */ new Set(["$or", "$and", "$nor", "$text", "$where", "$expr", "$comment"]);
231
+ function checkUnindexedFields(definition, filter) {
232
+ if (definition.options.warnUnindexedQueries !== true) return;
233
+ const covered = /* @__PURE__ */ new Set();
234
+ for (const fi of definition.fieldIndexes) {
235
+ covered.add(fi.field);
236
+ }
237
+ for (const ci of definition.compoundIndexes) {
238
+ const firstField = Object.keys(ci.fields)[0];
239
+ if (firstField !== void 0) {
240
+ covered.add(firstField);
241
+ }
242
+ }
243
+ for (const key of Object.keys(filter)) {
244
+ if (key === "_id") continue;
245
+ if (SKIP_OPERATORS.has(key)) continue;
246
+ if (!covered.has(key)) {
247
+ console.warn(`[zodmon] warn: query on '${definition.name}' uses unindexed field '${key}'`);
248
+ }
249
+ }
250
+ }
251
+
252
+ // src/query/cursor.ts
253
+ import { z as z2 } from "zod";
254
+
255
+ // src/crud/paginate.ts
256
+ import { ObjectId } from "mongodb";
257
+ function serializeValue(value) {
258
+ if (value instanceof ObjectId) return { $oid: value.toHexString() };
259
+ if (value instanceof Date) return { $date: value.getTime() };
260
+ return value;
261
+ }
262
+ function deserializeValue(value) {
263
+ if (value != null && typeof value === "object") {
264
+ if ("$oid" in value) return new ObjectId(value.$oid);
265
+ if ("$date" in value) return new Date(value.$date);
266
+ }
267
+ return value;
268
+ }
269
+ function encodeCursor(doc, sortKeys2, direction) {
270
+ const values = sortKeys2.map(([field]) => serializeValue(doc[field]));
271
+ return btoa(JSON.stringify([direction, ...values]));
272
+ }
273
+ function decodeCursor(cursor) {
274
+ let parsed;
275
+ try {
276
+ parsed = JSON.parse(atob(cursor));
277
+ } catch {
278
+ throw new Error("Invalid cursor: malformed encoding");
279
+ }
280
+ if (!Array.isArray(parsed) || parsed.length < 1) {
281
+ throw new Error("Invalid cursor: expected non-empty array");
282
+ }
283
+ const [direction, ...rawValues] = parsed;
284
+ if (direction !== "f" && direction !== "b") {
285
+ throw new Error("Invalid cursor: unknown direction flag");
286
+ }
287
+ return { direction, values: rawValues.map(deserializeValue) };
288
+ }
289
+ function buildCursorFilter(sortKeys2, values, isBackward) {
290
+ const clauses = [];
291
+ for (let i = 0; i < sortKeys2.length; i++) {
292
+ const clause = {};
293
+ for (let j = 0; j < i; j++) {
294
+ clause[sortKeys2[j][0]] = values[j];
295
+ }
296
+ const [field, direction] = sortKeys2[i];
297
+ const isAsc = direction === 1;
298
+ const op = isAsc !== isBackward ? "$gt" : "$lt";
299
+ if (values[i] === null) {
300
+ clause[field] = { $ne: null };
301
+ } else {
302
+ clause[field] = { [op]: values[i] };
303
+ }
304
+ clauses.push(clause);
305
+ }
306
+ return { $or: clauses };
307
+ }
308
+ function resolveSortKeys(sortSpec) {
309
+ const entries = sortSpec ? Object.entries(sortSpec) : [];
310
+ if (!entries.some(([field]) => field === "_id")) {
311
+ entries.push(["_id", 1]);
312
+ }
313
+ return entries;
314
+ }
315
+
36
316
  // src/query/cursor.ts
37
- import { z } from "zod";
38
317
  var TypedFindCursor = class {
39
318
  /** @internal */
40
319
  cursor;
@@ -45,11 +324,21 @@ var TypedFindCursor = class {
45
324
  /** @internal */
46
325
  mode;
47
326
  /** @internal */
48
- constructor(cursor, definition, mode) {
327
+ nativeCollection;
328
+ /** @internal */
329
+ // biome-ignore lint/suspicious/noExplicitAny: TypedFilter is not assignable to MongoDB's Filter; stored opaquely for paginate
330
+ filter;
331
+ /** @internal */
332
+ sortSpec;
333
+ /** @internal */
334
+ constructor(cursor, definition, mode, nativeCollection, filter) {
49
335
  this.cursor = cursor;
50
336
  this.schema = definition.schema;
51
337
  this.collectionName = definition.name;
52
338
  this.mode = mode;
339
+ this.nativeCollection = nativeCollection;
340
+ this.filter = filter;
341
+ this.sortSpec = null;
53
342
  }
54
343
  /**
55
344
  * Set the sort order for the query.
@@ -66,6 +355,7 @@ var TypedFindCursor = class {
66
355
  * ```
67
356
  */
68
357
  sort(spec) {
358
+ this.sortSpec = spec;
69
359
  this.cursor.sort(spec);
70
360
  return this;
71
361
  }
@@ -99,6 +389,80 @@ var TypedFindCursor = class {
99
389
  this.cursor.limit(n);
100
390
  return this;
101
391
  }
392
+ /**
393
+ * Force the query optimizer to use the specified index.
394
+ *
395
+ * Only accepts index names that were declared via `.name()` in the
396
+ * collection definition. If no named indexes exist, any string is accepted.
397
+ *
398
+ * @param indexName - The name of a declared compound index.
399
+ * @returns `this` for chaining.
400
+ *
401
+ * @example
402
+ * ```ts
403
+ * const Users = collection('users', { email: z.string(), role: z.string() }, {
404
+ * indexes: [index({ email: 1, role: -1 }).name('email_role_idx')],
405
+ * })
406
+ * const admins = await users.find({ role: 'admin' })
407
+ * .hint('email_role_idx')
408
+ * .toArray()
409
+ * ```
410
+ */
411
+ hint(indexName) {
412
+ this.cursor.hint(indexName);
413
+ return this;
414
+ }
415
+ async paginate(opts) {
416
+ const sortRecord = this.sortSpec ? this.sortSpec : null;
417
+ const sortKeys2 = resolveSortKeys(sortRecord);
418
+ const sort = Object.fromEntries(sortKeys2);
419
+ if ("page" in opts) {
420
+ return await this.offsetPaginate(sortKeys2, sort, opts);
421
+ }
422
+ return await this.cursorPaginate(sortKeys2, sort, opts);
423
+ }
424
+ /** @internal Offset pagination implementation. */
425
+ async offsetPaginate(_sortKeys, sort, opts) {
426
+ const [total, raw2] = await Promise.all([
427
+ this.nativeCollection.countDocuments(this.filter),
428
+ this.nativeCollection.find(this.filter).sort(sort).skip((opts.page - 1) * opts.perPage).limit(opts.perPage).toArray()
429
+ ]);
430
+ const docs = raw2.map((doc) => this.validateDoc(doc));
431
+ const totalPages = Math.ceil(total / opts.perPage);
432
+ return {
433
+ docs,
434
+ total,
435
+ page: opts.page,
436
+ perPage: opts.perPage,
437
+ totalPages,
438
+ hasNext: opts.page < totalPages,
439
+ hasPrev: opts.page > 1
440
+ };
441
+ }
442
+ /** @internal Cursor pagination implementation. */
443
+ async cursorPaginate(sortKeys2, sort, opts) {
444
+ let isBackward = false;
445
+ let combinedFilter = this.filter;
446
+ if (opts.cursor) {
447
+ const decoded = decodeCursor(opts.cursor);
448
+ isBackward = decoded.direction === "b";
449
+ const cursorFilter = buildCursorFilter(sortKeys2, decoded.values, isBackward);
450
+ combinedFilter = this.filter && Object.keys(this.filter).length > 0 ? { $and: [this.filter, cursorFilter] } : cursorFilter;
451
+ }
452
+ const effectiveSort = isBackward ? Object.fromEntries(sortKeys2.map(([f, d]) => [f, d === 1 ? -1 : 1])) : sort;
453
+ const raw2 = await this.nativeCollection.find(combinedFilter).sort(effectiveSort).limit(opts.limit + 1).toArray();
454
+ const hasMore = raw2.length > opts.limit;
455
+ if (hasMore) raw2.pop();
456
+ if (isBackward) raw2.reverse();
457
+ const docs = raw2.map((doc) => this.validateDoc(doc));
458
+ return {
459
+ docs,
460
+ hasNext: isBackward ? true : hasMore,
461
+ hasPrev: isBackward ? hasMore : opts.cursor != null,
462
+ startCursor: docs.length > 0 ? encodeCursor(docs[0], sortKeys2, "b") : null,
463
+ endCursor: docs.length > 0 ? encodeCursor(docs[docs.length - 1], sortKeys2, "f") : null
464
+ };
465
+ }
102
466
  /**
103
467
  * Execute the query and return all matching documents as an array.
104
468
  *
@@ -146,7 +510,7 @@ var TypedFindCursor = class {
146
510
  try {
147
511
  return this.schema.parse(raw2);
148
512
  } catch (err) {
149
- if (err instanceof z.ZodError) {
513
+ if (err instanceof z2.ZodError) {
150
514
  throw new ZodmonValidationError(this.collectionName, err);
151
515
  }
152
516
  throw err;
@@ -156,6 +520,7 @@ var TypedFindCursor = class {
156
520
 
157
521
  // src/crud/find.ts
158
522
  async function findOne(handle, filter, options) {
523
+ checkUnindexedFields(handle.definition, filter);
159
524
  const findOptions = options?.project ? { projection: options.project } : void 0;
160
525
  const raw2 = await handle.native.findOne(filter, findOptions);
161
526
  if (!raw2) return null;
@@ -166,7 +531,7 @@ async function findOne(handle, filter, options) {
166
531
  try {
167
532
  return handle.definition.schema.parse(raw2);
168
533
  } catch (err) {
169
- if (err instanceof z2.ZodError) {
534
+ if (err instanceof z3.ZodError) {
170
535
  throw new ZodmonValidationError(handle.definition.name, err);
171
536
  }
172
537
  throw err;
@@ -180,20 +545,21 @@ async function findOneOrThrow(handle, filter, options) {
180
545
  return doc;
181
546
  }
182
547
  function find(handle, filter, options) {
548
+ checkUnindexedFields(handle.definition, filter);
183
549
  const raw2 = handle.native.find(filter);
184
550
  const cursor = raw2;
185
551
  const mode = options?.validate !== void 0 ? options.validate : handle.definition.options.validation;
186
- return new TypedFindCursor(cursor, handle.definition, mode);
552
+ return new TypedFindCursor(cursor, handle.definition, mode, handle.native, filter);
187
553
  }
188
554
 
189
555
  // src/crud/insert.ts
190
- import { z as z3 } from "zod";
556
+ import { z as z4 } from "zod";
191
557
  async function insertOne(handle, doc) {
192
558
  let parsed;
193
559
  try {
194
560
  parsed = handle.definition.schema.parse(doc);
195
561
  } catch (err) {
196
- if (err instanceof z3.ZodError) {
562
+ if (err instanceof z4.ZodError) {
197
563
  throw new ZodmonValidationError(handle.definition.name, err);
198
564
  }
199
565
  throw err;
@@ -208,7 +574,7 @@ async function insertMany(handle, docs) {
208
574
  try {
209
575
  parsed.push(handle.definition.schema.parse(doc));
210
576
  } catch (err) {
211
- if (err instanceof z3.ZodError) {
577
+ if (err instanceof z4.ZodError) {
212
578
  throw new ZodmonValidationError(handle.definition.name, err);
213
579
  }
214
580
  throw err;
@@ -218,6 +584,45 @@ async function insertMany(handle, docs) {
218
584
  return parsed;
219
585
  }
220
586
 
587
+ // src/crud/update.ts
588
+ import { z as z5 } from "zod";
589
+ async function updateOne(handle, filter, update, options) {
590
+ return await handle.native.updateOne(filter, update, options);
591
+ }
592
+ async function updateMany(handle, filter, update, options) {
593
+ return await handle.native.updateMany(filter, update, options);
594
+ }
595
+ async function findOneAndUpdate(handle, filter, update, options) {
596
+ const driverOptions = {
597
+ returnDocument: options?.returnDocument ?? "after",
598
+ includeResultMetadata: false
599
+ };
600
+ if (options?.upsert !== void 0) {
601
+ driverOptions["upsert"] = options.upsert;
602
+ }
603
+ const result = await handle.native.findOneAndUpdate(
604
+ // biome-ignore lint/suspicious/noExplicitAny: TypedFilter intersection type is not directly assignable to MongoDB's Filter
605
+ filter,
606
+ // biome-ignore lint/suspicious/noExplicitAny: TypedUpdateFilter intersection type is not directly assignable to MongoDB's UpdateFilter
607
+ update,
608
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic options object is not assignable to driver's FindOneAndUpdateOptions under exactOptionalPropertyTypes
609
+ driverOptions
610
+ );
611
+ if (!result) return null;
612
+ const mode = options?.validate !== void 0 ? options.validate : handle.definition.options.validation;
613
+ if (mode === false || mode === "passthrough") {
614
+ return result;
615
+ }
616
+ try {
617
+ return handle.definition.schema.parse(result);
618
+ } catch (err) {
619
+ if (err instanceof z5.ZodError) {
620
+ throw new ZodmonValidationError(handle.definition.name, err);
621
+ }
622
+ throw err;
623
+ }
624
+ }
625
+
221
626
  // src/client/handle.ts
222
627
  var CollectionHandle = class {
223
628
  /** The collection definition containing schema, name, and index metadata. */
@@ -339,6 +744,169 @@ var CollectionHandle = class {
339
744
  find(filter, options) {
340
745
  return find(this, filter, options);
341
746
  }
747
+ /**
748
+ * Update a single document matching the filter.
749
+ *
750
+ * Applies the update operators to the first document that matches the filter.
751
+ * Does not validate the update against the Zod schema — validation happens
752
+ * at the field-operator level through {@link TypedUpdateFilter}.
753
+ *
754
+ * @param filter - Type-safe filter to match documents.
755
+ * @param update - Type-safe update operators to apply.
756
+ * @param options - Optional settings such as `upsert`.
757
+ * @returns The MongoDB `UpdateResult` with match/modify counts.
758
+ *
759
+ * @example
760
+ * ```ts
761
+ * const users = db.use(Users)
762
+ * const result = await users.updateOne({ name: 'Ada' }, { $set: { role: 'admin' } })
763
+ * console.log(result.modifiedCount) // 1
764
+ * ```
765
+ */
766
+ async updateOne(filter, update, options) {
767
+ return await updateOne(this, filter, update, options);
768
+ }
769
+ /**
770
+ * Update all documents matching the filter.
771
+ *
772
+ * Applies the update operators to every document that matches the filter.
773
+ * Does not validate the update against the Zod schema — validation happens
774
+ * at the field-operator level through {@link TypedUpdateFilter}.
775
+ *
776
+ * @param filter - Type-safe filter to match documents.
777
+ * @param update - Type-safe update operators to apply.
778
+ * @param options - Optional settings such as `upsert`.
779
+ * @returns The MongoDB `UpdateResult` with match/modify counts.
780
+ *
781
+ * @example
782
+ * ```ts
783
+ * const users = db.use(Users)
784
+ * const result = await users.updateMany({ role: 'guest' }, { $set: { role: 'user' } })
785
+ * console.log(result.modifiedCount) // number of guests promoted
786
+ * ```
787
+ */
788
+ async updateMany(filter, update, options) {
789
+ return await updateMany(this, filter, update, options);
790
+ }
791
+ /**
792
+ * Find a single document matching the filter, apply an update, and return the document.
793
+ *
794
+ * By default, returns the document **after** the update is applied. Set
795
+ * `returnDocument: 'before'` to get the pre-update snapshot. The returned
796
+ * document is validated against the collection's Zod schema using the same
797
+ * resolution logic as {@link findOne}.
798
+ *
799
+ * @param filter - Type-safe filter to match documents.
800
+ * @param update - Type-safe update operators to apply.
801
+ * @param options - Optional settings: `returnDocument`, `upsert`, `validate`.
802
+ * @returns The matched document (before or after update), or `null` if no document matches.
803
+ * @throws {ZodmonValidationError} When the returned document fails schema validation in strict mode.
804
+ *
805
+ * @example
806
+ * ```ts
807
+ * const users = db.use(Users)
808
+ * const user = await users.findOneAndUpdate(
809
+ * { name: 'Ada' },
810
+ * { $set: { role: 'admin' } },
811
+ * )
812
+ * if (user) console.log(user.role) // 'admin' (returned after update)
813
+ * ```
814
+ */
815
+ async findOneAndUpdate(filter, update, options) {
816
+ return await findOneAndUpdate(this, filter, update, options);
817
+ }
818
+ /**
819
+ * Delete a single document matching the filter.
820
+ *
821
+ * Removes the first document that matches the filter from the collection.
822
+ * No validation is performed — the document is deleted directly through
823
+ * the MongoDB driver.
824
+ *
825
+ * @param filter - Type-safe filter to match documents.
826
+ * @returns The MongoDB `DeleteResult` with the deleted count.
827
+ *
828
+ * @example
829
+ * ```ts
830
+ * const users = db.use(Users)
831
+ * const result = await users.deleteOne({ name: 'Ada' })
832
+ * console.log(result.deletedCount) // 1
833
+ * ```
834
+ */
835
+ async deleteOne(filter) {
836
+ return await deleteOne(this, filter);
837
+ }
838
+ /**
839
+ * Delete all documents matching the filter.
840
+ *
841
+ * Removes every document that matches the filter from the collection.
842
+ * No validation is performed — documents are deleted directly through
843
+ * the MongoDB driver.
844
+ *
845
+ * @param filter - Type-safe filter to match documents.
846
+ * @returns The MongoDB `DeleteResult` with the deleted count.
847
+ *
848
+ * @example
849
+ * ```ts
850
+ * const users = db.use(Users)
851
+ * const result = await users.deleteMany({ role: 'guest' })
852
+ * console.log(result.deletedCount) // number of guests removed
853
+ * ```
854
+ */
855
+ async deleteMany(filter) {
856
+ return await deleteMany(this, filter);
857
+ }
858
+ /**
859
+ * Find a single document matching the filter, delete it, and return the document.
860
+ *
861
+ * Returns the deleted document, or `null` if no document matches the filter.
862
+ * The returned document is validated against the collection's Zod schema
863
+ * using the same resolution logic as {@link findOne}.
864
+ *
865
+ * @param filter - Type-safe filter to match documents.
866
+ * @param options - Optional settings: `validate`.
867
+ * @returns The deleted document, or `null` if no document matches.
868
+ * @throws {ZodmonValidationError} When the returned document fails schema validation in strict mode.
869
+ *
870
+ * @example
871
+ * ```ts
872
+ * const users = db.use(Users)
873
+ * const user = await users.findOneAndDelete({ name: 'Ada' })
874
+ * if (user) console.log(user.name) // 'Ada' (the deleted document)
875
+ * ```
876
+ */
877
+ async findOneAndDelete(filter, options) {
878
+ return await findOneAndDelete(this, filter, options);
879
+ }
880
+ /**
881
+ * Synchronize the indexes declared in this collection's schema with MongoDB.
882
+ *
883
+ * Compares the desired indexes (from field-level `.index()` / `.unique()` /
884
+ * `.text()` / `.expireAfter()` and compound `indexes` in collection options)
885
+ * with the indexes that currently exist in MongoDB, then creates, drops, or
886
+ * reports differences depending on the options.
887
+ *
888
+ * @param options - Optional sync behavior (dryRun, dropOrphaned).
889
+ * @returns A summary of created, dropped, skipped, and stale indexes.
890
+ *
891
+ * @example
892
+ * ```ts
893
+ * const users = db.use(Users)
894
+ * const result = await users.syncIndexes()
895
+ * console.log('Created:', result.created)
896
+ * console.log('Stale:', result.stale.map(s => s.name))
897
+ * ```
898
+ *
899
+ * @example
900
+ * ```ts
901
+ * // Dry run to preview changes without modifying the database
902
+ * const diff = await users.syncIndexes({ dryRun: true })
903
+ * console.log('Would create:', diff.created)
904
+ * console.log('Would drop:', diff.dropped)
905
+ * ```
906
+ */
907
+ async syncIndexes(options) {
908
+ return await syncIndexes(this, options);
909
+ }
342
910
  };
343
911
 
344
912
  // src/client/client.ts
@@ -364,19 +932,42 @@ var Database = class {
364
932
  */
365
933
  use(def) {
366
934
  this._collections.set(def.name, def);
367
- const native = this._db.collection(def.name);
935
+ const native = this._db.collection(
936
+ def.name
937
+ );
368
938
  return new CollectionHandle(
369
939
  def,
370
940
  native
371
941
  );
372
942
  }
373
943
  /**
374
- * Synchronize indexes defined in registered collections with MongoDB.
944
+ * Synchronize indexes for all registered collections with MongoDB.
945
+ *
946
+ * Iterates every collection registered via {@link use} and calls
947
+ * {@link syncIndexes} on each one. Returns a record keyed by collection
948
+ * name with the sync result for each.
949
+ *
950
+ * @param options - Optional sync behavior (dryRun, dropOrphaned).
951
+ * @returns A record mapping collection names to their sync results.
375
952
  *
376
- * Stub — full implementation in TASK-92.
953
+ * @example
954
+ * ```ts
955
+ * const db = createClient('mongodb://localhost:27017', 'myapp')
956
+ * db.use(Users)
957
+ * db.use(Posts)
958
+ * const results = await db.syncIndexes()
959
+ * console.log(results['users'].created) // ['email_1']
960
+ * console.log(results['posts'].created) // ['title_1']
961
+ * ```
377
962
  */
378
- syncIndexes() {
379
- return Promise.resolve();
963
+ async syncIndexes(options) {
964
+ const results = {};
965
+ for (const [name, def] of this._collections) {
966
+ const native = this._db.collection(name);
967
+ const handle = new CollectionHandle(def, native);
968
+ results[name] = await syncIndexes(handle, options);
969
+ }
970
+ return results;
380
971
  }
381
972
  /**
382
973
  * Execute a function within a MongoDB transaction with auto-commit/rollback.
@@ -416,14 +1007,14 @@ function createClient(uri, dbNameOrOptions, maybeOptions) {
416
1007
  }
417
1008
 
418
1009
  // src/collection/collection.ts
419
- import { ObjectId as ObjectId2 } from "mongodb";
420
- import { z as z7 } from "zod";
1010
+ import { ObjectId as ObjectId3 } from "mongodb";
1011
+ import { z as z9 } from "zod";
421
1012
 
422
1013
  // src/schema/extensions.ts
423
- import { z as z5 } from "zod";
1014
+ import { z as z7 } from "zod";
424
1015
 
425
1016
  // src/schema/ref.ts
426
- import { z as z4 } from "zod";
1017
+ import { z as z6 } from "zod";
427
1018
  var refMetadata = /* @__PURE__ */ new WeakMap();
428
1019
  function getRefMetadata(schema) {
429
1020
  if (typeof schema !== "object" || schema === null) return void 0;
@@ -431,7 +1022,7 @@ function getRefMetadata(schema) {
431
1022
  }
432
1023
  var REF_GUARD = /* @__PURE__ */ Symbol.for("zodmon_ref");
433
1024
  function installRefExtension() {
434
- const proto = z4.ZodType.prototype;
1025
+ const proto = z6.ZodType.prototype;
435
1026
  if (REF_GUARD in proto) return;
436
1027
  Object.defineProperty(proto, "ref", {
437
1028
  value(collection2) {
@@ -458,7 +1049,7 @@ function getIndexMetadata(schema) {
458
1049
  }
459
1050
  var GUARD = /* @__PURE__ */ Symbol.for("zodmon_extensions");
460
1051
  function installExtensions() {
461
- const proto = z5.ZodType.prototype;
1052
+ const proto = z7.ZodType.prototype;
462
1053
  if (GUARD in proto) return;
463
1054
  Object.defineProperty(proto, "index", {
464
1055
  /**
@@ -556,14 +1147,14 @@ function installExtensions() {
556
1147
  installExtensions();
557
1148
 
558
1149
  // src/schema/object-id.ts
559
- import { ObjectId } from "mongodb";
560
- import { z as z6 } from "zod";
1150
+ import { ObjectId as ObjectId2 } from "mongodb";
1151
+ import { z as z8 } from "zod";
561
1152
  var OBJECT_ID_HEX = /^[a-f\d]{24}$/i;
562
1153
  function objectId() {
563
- return z6.custom((val) => {
564
- if (val instanceof ObjectId) return true;
1154
+ return z8.custom((val) => {
1155
+ if (val instanceof ObjectId2) return true;
565
1156
  return typeof val === "string" && OBJECT_ID_HEX.test(val);
566
- }, "Invalid ObjectId").transform((val) => val instanceof ObjectId ? val : ObjectId.createFromHexString(val));
1157
+ }, "Invalid ObjectId").transform((val) => val instanceof ObjectId2 ? val : ObjectId2.createFromHexString(val));
567
1158
  }
568
1159
 
569
1160
  // src/collection/collection.ts
@@ -578,8 +1169,8 @@ function extractFieldIndexes(shape) {
578
1169
  return result;
579
1170
  }
580
1171
  function collection(name, shape, options) {
581
- const resolvedShape = "_id" in shape ? shape : { _id: objectId().default(() => new ObjectId2()), ...shape };
582
- const schema = z7.object(resolvedShape);
1172
+ const resolvedShape = "_id" in shape ? shape : { _id: objectId().default(() => new ObjectId3()), ...shape };
1173
+ const schema = z9.object(resolvedShape);
583
1174
  const fieldIndexes = extractFieldIndexes(shape);
584
1175
  const { indexes: compoundIndexes, validation, ...rest } = options ?? {};
585
1176
  return {
@@ -591,6 +1182,8 @@ function collection(name, shape, options) {
591
1182
  schema,
592
1183
  shape,
593
1184
  fieldIndexes,
1185
+ // Safe cast: compoundIndexes is TIndexes at runtime (or an empty array when
1186
+ // no options provided). The spread into [...TIndexes] preserves the tuple type.
594
1187
  compoundIndexes: compoundIndexes ?? [],
595
1188
  options: {
596
1189
  validation: validation ?? "strict",
@@ -615,12 +1208,29 @@ var IndexBuilder = class _IndexBuilder {
615
1208
  options
616
1209
  });
617
1210
  }
1211
+ // Safe cast: _clone returns IndexBuilder<TKeys> but `this` may carry an
1212
+ // intersection from .name(). The cast is safe because _clone preserves all fields.
618
1213
  unique() {
619
1214
  return this._clone({ ...this.options, unique: true });
620
1215
  }
1216
+ // Safe cast: same reasoning as unique().
621
1217
  sparse() {
622
1218
  return this._clone({ ...this.options, sparse: true });
623
1219
  }
1220
+ /**
1221
+ * Set a custom name for this index, preserving the literal type.
1222
+ *
1223
+ * The returned builder carries the literal name type via an intersection,
1224
+ * enabling type-safe `.hint()` on cursors that only accepts declared names.
1225
+ *
1226
+ * @param name - The index name.
1227
+ * @returns A new IndexBuilder with the name recorded at the type level.
1228
+ *
1229
+ * @example
1230
+ * ```ts
1231
+ * index({ email: 1, role: -1 }).name('email_role_idx')
1232
+ * ```
1233
+ */
624
1234
  name(name) {
625
1235
  return this._clone({ ...this.options, name });
626
1236
  }
@@ -630,14 +1240,14 @@ function index(fields) {
630
1240
  }
631
1241
 
632
1242
  // src/helpers/oid.ts
633
- import { ObjectId as ObjectId3 } from "mongodb";
1243
+ import { ObjectId as ObjectId4 } from "mongodb";
634
1244
  function oid(value) {
635
- if (value === void 0) return new ObjectId3();
636
- if (value instanceof ObjectId3) return value;
637
- return ObjectId3.createFromHexString(value);
1245
+ if (value === void 0) return new ObjectId4();
1246
+ if (value instanceof ObjectId4) return value;
1247
+ return ObjectId4.createFromHexString(value);
638
1248
  }
639
1249
  function isOid(value) {
640
- return value instanceof ObjectId3;
1250
+ return value instanceof ObjectId4;
641
1251
  }
642
1252
 
643
1253
  // src/query/operators.ts
@@ -660,7 +1270,27 @@ var $or = (...filters) => ({ $or: filters });
660
1270
  var $and = (...filters) => ({ $and: filters });
661
1271
  var $nor = (...filters) => ({ $nor: filters });
662
1272
  var raw = (filter) => filter;
1273
+
1274
+ // src/query/namespace.ts
1275
+ var $ = {
1276
+ eq: $eq,
1277
+ ne: $ne,
1278
+ gt: $gt,
1279
+ gte: $gte,
1280
+ lt: $lt,
1281
+ lte: $lte,
1282
+ in: $in,
1283
+ nin: $nin,
1284
+ exists: $exists,
1285
+ regex: $regex,
1286
+ not: $not,
1287
+ or: $or,
1288
+ and: $and,
1289
+ nor: $nor,
1290
+ raw
1291
+ };
663
1292
  export {
1293
+ $,
664
1294
  $and,
665
1295
  $eq,
666
1296
  $exists,
@@ -681,23 +1311,34 @@ export {
681
1311
  TypedFindCursor,
682
1312
  ZodmonNotFoundError,
683
1313
  ZodmonValidationError,
1314
+ checkUnindexedFields,
684
1315
  collection,
685
1316
  createClient,
1317
+ deleteMany,
1318
+ deleteOne,
1319
+ extractComparableOptions,
686
1320
  extractDbName,
687
1321
  extractFieldIndexes,
688
1322
  find,
689
1323
  findOne,
1324
+ findOneAndDelete,
1325
+ findOneAndUpdate,
690
1326
  findOneOrThrow,
1327
+ generateIndexName,
691
1328
  getIndexMetadata,
692
1329
  getRefMetadata,
693
1330
  index,
694
1331
  insertMany,
695
1332
  insertOne,
696
- installExtensions,
697
- installRefExtension,
698
1333
  isOid,
699
1334
  objectId,
700
1335
  oid,
701
- raw
1336
+ raw,
1337
+ serializeIndexKey,
1338
+ syncIndexes,
1339
+ toCompoundIndexSpec,
1340
+ toFieldIndexSpec,
1341
+ updateMany,
1342
+ updateOne
702
1343
  };
703
1344
  //# sourceMappingURL=index.js.map