@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.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ $: () => $,
23
24
  $and: () => $and,
24
25
  $eq: () => $eq,
25
26
  $exists: () => $exists,
@@ -40,43 +41,205 @@ __export(index_exports, {
40
41
  TypedFindCursor: () => TypedFindCursor,
41
42
  ZodmonNotFoundError: () => ZodmonNotFoundError,
42
43
  ZodmonValidationError: () => ZodmonValidationError,
44
+ checkUnindexedFields: () => checkUnindexedFields,
43
45
  collection: () => collection,
44
46
  createClient: () => createClient,
47
+ deleteMany: () => deleteMany,
48
+ deleteOne: () => deleteOne,
49
+ extractComparableOptions: () => extractComparableOptions,
45
50
  extractDbName: () => extractDbName,
46
51
  extractFieldIndexes: () => extractFieldIndexes,
47
52
  find: () => find,
48
53
  findOne: () => findOne,
54
+ findOneAndDelete: () => findOneAndDelete,
55
+ findOneAndUpdate: () => findOneAndUpdate,
49
56
  findOneOrThrow: () => findOneOrThrow,
57
+ generateIndexName: () => generateIndexName,
50
58
  getIndexMetadata: () => getIndexMetadata,
51
59
  getRefMetadata: () => getRefMetadata,
52
60
  index: () => index,
53
61
  insertMany: () => insertMany,
54
62
  insertOne: () => insertOne,
55
- installExtensions: () => installExtensions,
56
- installRefExtension: () => installRefExtension,
57
63
  isOid: () => isOid,
58
64
  objectId: () => objectId,
59
65
  oid: () => oid,
60
- raw: () => raw
66
+ raw: () => raw,
67
+ serializeIndexKey: () => serializeIndexKey,
68
+ syncIndexes: () => syncIndexes,
69
+ toCompoundIndexSpec: () => toCompoundIndexSpec,
70
+ toFieldIndexSpec: () => toFieldIndexSpec,
71
+ updateMany: () => updateMany,
72
+ updateOne: () => updateOne
61
73
  });
62
74
  module.exports = __toCommonJS(index_exports);
63
75
 
64
76
  // src/client/client.ts
65
- var import_mongodb = require("mongodb");
77
+ var import_mongodb2 = require("mongodb");
66
78
 
67
- // src/crud/find.ts
68
- var import_zod2 = require("zod");
79
+ // src/indexes/spec.ts
80
+ function toFieldIndexSpec(def) {
81
+ const direction = def.text ? "text" : def.descending ? -1 : 1;
82
+ const key = { [def.field]: direction };
83
+ const options = {};
84
+ if (def.unique) options["unique"] = true;
85
+ if (def.sparse) options["sparse"] = true;
86
+ if (def.expireAfter !== void 0) options["expireAfterSeconds"] = def.expireAfter;
87
+ if (def.partial) options["partialFilterExpression"] = def.partial;
88
+ return { key, options };
89
+ }
90
+ function toCompoundIndexSpec(def) {
91
+ const key = { ...def.fields };
92
+ const options = {};
93
+ if (def.options?.unique) options["unique"] = true;
94
+ if (def.options?.sparse) options["sparse"] = true;
95
+ if (def.options?.name) options["name"] = def.options.name;
96
+ if (def.options?.partial) options["partialFilterExpression"] = def.options.partial;
97
+ return { key, options };
98
+ }
99
+ function serializeIndexKey(key) {
100
+ return Object.entries(key).map(([field, dir]) => `${field}:${dir}`).join(",");
101
+ }
69
102
 
70
- // src/errors/not-found.ts
71
- var ZodmonNotFoundError = class extends Error {
72
- name = "ZodmonNotFoundError";
73
- /** The MongoDB collection name where the query found no results. */
74
- collection;
75
- constructor(collection2) {
76
- super(`Document not found in "${collection2}"`);
77
- this.collection = collection2;
103
+ // src/indexes/sync.ts
104
+ var COMPARABLE_OPTION_KEYS = [
105
+ "unique",
106
+ "sparse",
107
+ "expireAfterSeconds",
108
+ "partialFilterExpression"
109
+ ];
110
+ function extractComparableOptions(info) {
111
+ const result = {};
112
+ for (const key of COMPARABLE_OPTION_KEYS) {
113
+ if (info[key] !== void 0) {
114
+ result[key] = info[key];
115
+ }
78
116
  }
79
- };
117
+ return result;
118
+ }
119
+ function generateIndexName(key) {
120
+ return Object.entries(key).map(([field, dir]) => `${field}_${dir}`).join("_");
121
+ }
122
+ function sortKeys(obj) {
123
+ const sorted = {};
124
+ for (const key of Object.keys(obj).sort()) {
125
+ sorted[key] = obj[key];
126
+ }
127
+ return sorted;
128
+ }
129
+ function resolveSpecName(spec) {
130
+ const specName = spec.options["name"];
131
+ return typeof specName === "string" ? specName : generateIndexName(spec.key);
132
+ }
133
+ function resolveExistingName(info, key) {
134
+ const infoName = info["name"];
135
+ if (typeof infoName === "string") return infoName;
136
+ return generateIndexName(key);
137
+ }
138
+ function optionsMatch(a, b) {
139
+ return JSON.stringify(sortKeys(stripName(a))) === JSON.stringify(sortKeys(stripName(b)));
140
+ }
141
+ function stripName(obj) {
142
+ const { name: _, ...rest } = obj;
143
+ return rest;
144
+ }
145
+ async function processDesiredSpec(spec, existingByKey, native, dryRun, dropOrphaned, acc) {
146
+ const serialized = serializeIndexKey(spec.key);
147
+ const existing = existingByKey.get(serialized);
148
+ if (!existing) {
149
+ if (!dryRun) await native.createIndex(spec.key, spec.options);
150
+ acc.created.push(resolveSpecName(spec));
151
+ return;
152
+ }
153
+ acc.matchedKeys.add(serialized);
154
+ const existingName = resolveExistingName(existing, spec.key);
155
+ const existingOpts = extractComparableOptions(existing);
156
+ if (optionsMatch(existingOpts, spec.options)) {
157
+ acc.skipped.push(existingName);
158
+ return;
159
+ }
160
+ if (dropOrphaned) {
161
+ if (!dryRun) {
162
+ await native.dropIndex(existingName);
163
+ await native.createIndex(spec.key, spec.options);
164
+ }
165
+ acc.dropped.push(existingName);
166
+ acc.created.push(resolveSpecName(spec));
167
+ return;
168
+ }
169
+ acc.stale.push({
170
+ name: existingName,
171
+ key: spec.key,
172
+ existing: existingOpts,
173
+ desired: spec.options
174
+ });
175
+ }
176
+ async function processOrphanedIndexes(existingIndexes, desiredKeys, matchedKeys, native, dryRun, dropOrphaned, dropped) {
177
+ for (const idx of existingIndexes) {
178
+ const rawName = idx["name"];
179
+ const name = typeof rawName === "string" ? rawName : "";
180
+ if (name === "_id_") continue;
181
+ const serialized = serializeIndexKey(idx["key"]);
182
+ if (matchedKeys.has(serialized) || desiredKeys.has(serialized)) continue;
183
+ if (dropOrphaned) {
184
+ if (!dryRun) await native.dropIndex(name);
185
+ dropped.push(name);
186
+ }
187
+ }
188
+ }
189
+ async function listIndexesSafe(native) {
190
+ try {
191
+ return await native.listIndexes().toArray();
192
+ } catch (err) {
193
+ if (err instanceof Error && err.message.includes("ns does not exist")) {
194
+ return [];
195
+ }
196
+ throw err;
197
+ }
198
+ }
199
+ async function syncIndexes(handle, options) {
200
+ const { dryRun = false, dropOrphaned = false } = options ?? {};
201
+ const native = handle.native;
202
+ const def = handle.definition;
203
+ const desiredSpecs = [
204
+ ...def.fieldIndexes.map(toFieldIndexSpec),
205
+ ...def.compoundIndexes.map(toCompoundIndexSpec)
206
+ ];
207
+ const existingIndexes = await listIndexesSafe(native);
208
+ const existingByKey = /* @__PURE__ */ new Map();
209
+ for (const idx of existingIndexes) {
210
+ const serialized = serializeIndexKey(idx["key"]);
211
+ existingByKey.set(serialized, idx);
212
+ }
213
+ const acc = {
214
+ created: [],
215
+ dropped: [],
216
+ skipped: [],
217
+ stale: [],
218
+ matchedKeys: /* @__PURE__ */ new Set()
219
+ };
220
+ for (const spec of desiredSpecs) {
221
+ await processDesiredSpec(spec, existingByKey, native, dryRun, dropOrphaned, acc);
222
+ }
223
+ const desiredKeys = new Set(desiredSpecs.map((s) => serializeIndexKey(s.key)));
224
+ await processOrphanedIndexes(
225
+ existingIndexes,
226
+ desiredKeys,
227
+ acc.matchedKeys,
228
+ native,
229
+ dryRun,
230
+ dropOrphaned,
231
+ acc.dropped
232
+ );
233
+ return {
234
+ created: acc.created,
235
+ dropped: acc.dropped,
236
+ skipped: acc.skipped,
237
+ stale: acc.stale
238
+ };
239
+ }
240
+
241
+ // src/crud/delete.ts
242
+ var import_zod = require("zod");
80
243
 
81
244
  // src/errors/validation.ts
82
245
  var ZodmonValidationError = class extends Error {
@@ -96,8 +259,136 @@ var ZodmonValidationError = class extends Error {
96
259
  }
97
260
  };
98
261
 
262
+ // src/crud/delete.ts
263
+ async function deleteOne(handle, filter) {
264
+ return await handle.native.deleteOne(filter);
265
+ }
266
+ async function deleteMany(handle, filter) {
267
+ return await handle.native.deleteMany(filter);
268
+ }
269
+ async function findOneAndDelete(handle, filter, options) {
270
+ const result = await handle.native.findOneAndDelete(
271
+ // biome-ignore lint/suspicious/noExplicitAny: TypedFilter intersection type is not directly assignable to MongoDB's Filter
272
+ filter,
273
+ { includeResultMetadata: false }
274
+ );
275
+ if (!result) return null;
276
+ const mode = options?.validate !== void 0 ? options.validate : handle.definition.options.validation;
277
+ if (mode === false || mode === "passthrough") {
278
+ return result;
279
+ }
280
+ try {
281
+ return handle.definition.schema.parse(result);
282
+ } catch (err) {
283
+ if (err instanceof import_zod.z.ZodError) {
284
+ throw new ZodmonValidationError(handle.definition.name, err);
285
+ }
286
+ throw err;
287
+ }
288
+ }
289
+
290
+ // src/crud/find.ts
291
+ var import_zod3 = require("zod");
292
+
293
+ // src/errors/not-found.ts
294
+ var ZodmonNotFoundError = class extends Error {
295
+ name = "ZodmonNotFoundError";
296
+ /** The MongoDB collection name where the query found no results. */
297
+ collection;
298
+ constructor(collection2) {
299
+ super(`Document not found in "${collection2}"`);
300
+ this.collection = collection2;
301
+ }
302
+ };
303
+
304
+ // src/indexes/warn.ts
305
+ var SKIP_OPERATORS = /* @__PURE__ */ new Set(["$or", "$and", "$nor", "$text", "$where", "$expr", "$comment"]);
306
+ function checkUnindexedFields(definition, filter) {
307
+ if (definition.options.warnUnindexedQueries !== true) return;
308
+ const covered = /* @__PURE__ */ new Set();
309
+ for (const fi of definition.fieldIndexes) {
310
+ covered.add(fi.field);
311
+ }
312
+ for (const ci of definition.compoundIndexes) {
313
+ const firstField = Object.keys(ci.fields)[0];
314
+ if (firstField !== void 0) {
315
+ covered.add(firstField);
316
+ }
317
+ }
318
+ for (const key of Object.keys(filter)) {
319
+ if (key === "_id") continue;
320
+ if (SKIP_OPERATORS.has(key)) continue;
321
+ if (!covered.has(key)) {
322
+ console.warn(`[zodmon] warn: query on '${definition.name}' uses unindexed field '${key}'`);
323
+ }
324
+ }
325
+ }
326
+
327
+ // src/query/cursor.ts
328
+ var import_zod2 = require("zod");
329
+
330
+ // src/crud/paginate.ts
331
+ var import_mongodb = require("mongodb");
332
+ function serializeValue(value) {
333
+ if (value instanceof import_mongodb.ObjectId) return { $oid: value.toHexString() };
334
+ if (value instanceof Date) return { $date: value.getTime() };
335
+ return value;
336
+ }
337
+ function deserializeValue(value) {
338
+ if (value != null && typeof value === "object") {
339
+ if ("$oid" in value) return new import_mongodb.ObjectId(value.$oid);
340
+ if ("$date" in value) return new Date(value.$date);
341
+ }
342
+ return value;
343
+ }
344
+ function encodeCursor(doc, sortKeys2, direction) {
345
+ const values = sortKeys2.map(([field]) => serializeValue(doc[field]));
346
+ return btoa(JSON.stringify([direction, ...values]));
347
+ }
348
+ function decodeCursor(cursor) {
349
+ let parsed;
350
+ try {
351
+ parsed = JSON.parse(atob(cursor));
352
+ } catch {
353
+ throw new Error("Invalid cursor: malformed encoding");
354
+ }
355
+ if (!Array.isArray(parsed) || parsed.length < 1) {
356
+ throw new Error("Invalid cursor: expected non-empty array");
357
+ }
358
+ const [direction, ...rawValues] = parsed;
359
+ if (direction !== "f" && direction !== "b") {
360
+ throw new Error("Invalid cursor: unknown direction flag");
361
+ }
362
+ return { direction, values: rawValues.map(deserializeValue) };
363
+ }
364
+ function buildCursorFilter(sortKeys2, values, isBackward) {
365
+ const clauses = [];
366
+ for (let i = 0; i < sortKeys2.length; i++) {
367
+ const clause = {};
368
+ for (let j = 0; j < i; j++) {
369
+ clause[sortKeys2[j][0]] = values[j];
370
+ }
371
+ const [field, direction] = sortKeys2[i];
372
+ const isAsc = direction === 1;
373
+ const op = isAsc !== isBackward ? "$gt" : "$lt";
374
+ if (values[i] === null) {
375
+ clause[field] = { $ne: null };
376
+ } else {
377
+ clause[field] = { [op]: values[i] };
378
+ }
379
+ clauses.push(clause);
380
+ }
381
+ return { $or: clauses };
382
+ }
383
+ function resolveSortKeys(sortSpec) {
384
+ const entries = sortSpec ? Object.entries(sortSpec) : [];
385
+ if (!entries.some(([field]) => field === "_id")) {
386
+ entries.push(["_id", 1]);
387
+ }
388
+ return entries;
389
+ }
390
+
99
391
  // src/query/cursor.ts
100
- var import_zod = require("zod");
101
392
  var TypedFindCursor = class {
102
393
  /** @internal */
103
394
  cursor;
@@ -108,11 +399,21 @@ var TypedFindCursor = class {
108
399
  /** @internal */
109
400
  mode;
110
401
  /** @internal */
111
- constructor(cursor, definition, mode) {
402
+ nativeCollection;
403
+ /** @internal */
404
+ // biome-ignore lint/suspicious/noExplicitAny: TypedFilter is not assignable to MongoDB's Filter; stored opaquely for paginate
405
+ filter;
406
+ /** @internal */
407
+ sortSpec;
408
+ /** @internal */
409
+ constructor(cursor, definition, mode, nativeCollection, filter) {
112
410
  this.cursor = cursor;
113
411
  this.schema = definition.schema;
114
412
  this.collectionName = definition.name;
115
413
  this.mode = mode;
414
+ this.nativeCollection = nativeCollection;
415
+ this.filter = filter;
416
+ this.sortSpec = null;
116
417
  }
117
418
  /**
118
419
  * Set the sort order for the query.
@@ -129,6 +430,7 @@ var TypedFindCursor = class {
129
430
  * ```
130
431
  */
131
432
  sort(spec) {
433
+ this.sortSpec = spec;
132
434
  this.cursor.sort(spec);
133
435
  return this;
134
436
  }
@@ -162,6 +464,80 @@ var TypedFindCursor = class {
162
464
  this.cursor.limit(n);
163
465
  return this;
164
466
  }
467
+ /**
468
+ * Force the query optimizer to use the specified index.
469
+ *
470
+ * Only accepts index names that were declared via `.name()` in the
471
+ * collection definition. If no named indexes exist, any string is accepted.
472
+ *
473
+ * @param indexName - The name of a declared compound index.
474
+ * @returns `this` for chaining.
475
+ *
476
+ * @example
477
+ * ```ts
478
+ * const Users = collection('users', { email: z.string(), role: z.string() }, {
479
+ * indexes: [index({ email: 1, role: -1 }).name('email_role_idx')],
480
+ * })
481
+ * const admins = await users.find({ role: 'admin' })
482
+ * .hint('email_role_idx')
483
+ * .toArray()
484
+ * ```
485
+ */
486
+ hint(indexName) {
487
+ this.cursor.hint(indexName);
488
+ return this;
489
+ }
490
+ async paginate(opts) {
491
+ const sortRecord = this.sortSpec ? this.sortSpec : null;
492
+ const sortKeys2 = resolveSortKeys(sortRecord);
493
+ const sort = Object.fromEntries(sortKeys2);
494
+ if ("page" in opts) {
495
+ return await this.offsetPaginate(sortKeys2, sort, opts);
496
+ }
497
+ return await this.cursorPaginate(sortKeys2, sort, opts);
498
+ }
499
+ /** @internal Offset pagination implementation. */
500
+ async offsetPaginate(_sortKeys, sort, opts) {
501
+ const [total, raw2] = await Promise.all([
502
+ this.nativeCollection.countDocuments(this.filter),
503
+ this.nativeCollection.find(this.filter).sort(sort).skip((opts.page - 1) * opts.perPage).limit(opts.perPage).toArray()
504
+ ]);
505
+ const docs = raw2.map((doc) => this.validateDoc(doc));
506
+ const totalPages = Math.ceil(total / opts.perPage);
507
+ return {
508
+ docs,
509
+ total,
510
+ page: opts.page,
511
+ perPage: opts.perPage,
512
+ totalPages,
513
+ hasNext: opts.page < totalPages,
514
+ hasPrev: opts.page > 1
515
+ };
516
+ }
517
+ /** @internal Cursor pagination implementation. */
518
+ async cursorPaginate(sortKeys2, sort, opts) {
519
+ let isBackward = false;
520
+ let combinedFilter = this.filter;
521
+ if (opts.cursor) {
522
+ const decoded = decodeCursor(opts.cursor);
523
+ isBackward = decoded.direction === "b";
524
+ const cursorFilter = buildCursorFilter(sortKeys2, decoded.values, isBackward);
525
+ combinedFilter = this.filter && Object.keys(this.filter).length > 0 ? { $and: [this.filter, cursorFilter] } : cursorFilter;
526
+ }
527
+ const effectiveSort = isBackward ? Object.fromEntries(sortKeys2.map(([f, d]) => [f, d === 1 ? -1 : 1])) : sort;
528
+ const raw2 = await this.nativeCollection.find(combinedFilter).sort(effectiveSort).limit(opts.limit + 1).toArray();
529
+ const hasMore = raw2.length > opts.limit;
530
+ if (hasMore) raw2.pop();
531
+ if (isBackward) raw2.reverse();
532
+ const docs = raw2.map((doc) => this.validateDoc(doc));
533
+ return {
534
+ docs,
535
+ hasNext: isBackward ? true : hasMore,
536
+ hasPrev: isBackward ? hasMore : opts.cursor != null,
537
+ startCursor: docs.length > 0 ? encodeCursor(docs[0], sortKeys2, "b") : null,
538
+ endCursor: docs.length > 0 ? encodeCursor(docs[docs.length - 1], sortKeys2, "f") : null
539
+ };
540
+ }
165
541
  /**
166
542
  * Execute the query and return all matching documents as an array.
167
543
  *
@@ -209,7 +585,7 @@ var TypedFindCursor = class {
209
585
  try {
210
586
  return this.schema.parse(raw2);
211
587
  } catch (err) {
212
- if (err instanceof import_zod.z.ZodError) {
588
+ if (err instanceof import_zod2.z.ZodError) {
213
589
  throw new ZodmonValidationError(this.collectionName, err);
214
590
  }
215
591
  throw err;
@@ -219,6 +595,7 @@ var TypedFindCursor = class {
219
595
 
220
596
  // src/crud/find.ts
221
597
  async function findOne(handle, filter, options) {
598
+ checkUnindexedFields(handle.definition, filter);
222
599
  const findOptions = options?.project ? { projection: options.project } : void 0;
223
600
  const raw2 = await handle.native.findOne(filter, findOptions);
224
601
  if (!raw2) return null;
@@ -229,7 +606,7 @@ async function findOne(handle, filter, options) {
229
606
  try {
230
607
  return handle.definition.schema.parse(raw2);
231
608
  } catch (err) {
232
- if (err instanceof import_zod2.z.ZodError) {
609
+ if (err instanceof import_zod3.z.ZodError) {
233
610
  throw new ZodmonValidationError(handle.definition.name, err);
234
611
  }
235
612
  throw err;
@@ -243,20 +620,21 @@ async function findOneOrThrow(handle, filter, options) {
243
620
  return doc;
244
621
  }
245
622
  function find(handle, filter, options) {
623
+ checkUnindexedFields(handle.definition, filter);
246
624
  const raw2 = handle.native.find(filter);
247
625
  const cursor = raw2;
248
626
  const mode = options?.validate !== void 0 ? options.validate : handle.definition.options.validation;
249
- return new TypedFindCursor(cursor, handle.definition, mode);
627
+ return new TypedFindCursor(cursor, handle.definition, mode, handle.native, filter);
250
628
  }
251
629
 
252
630
  // src/crud/insert.ts
253
- var import_zod3 = require("zod");
631
+ var import_zod4 = require("zod");
254
632
  async function insertOne(handle, doc) {
255
633
  let parsed;
256
634
  try {
257
635
  parsed = handle.definition.schema.parse(doc);
258
636
  } catch (err) {
259
- if (err instanceof import_zod3.z.ZodError) {
637
+ if (err instanceof import_zod4.z.ZodError) {
260
638
  throw new ZodmonValidationError(handle.definition.name, err);
261
639
  }
262
640
  throw err;
@@ -271,7 +649,7 @@ async function insertMany(handle, docs) {
271
649
  try {
272
650
  parsed.push(handle.definition.schema.parse(doc));
273
651
  } catch (err) {
274
- if (err instanceof import_zod3.z.ZodError) {
652
+ if (err instanceof import_zod4.z.ZodError) {
275
653
  throw new ZodmonValidationError(handle.definition.name, err);
276
654
  }
277
655
  throw err;
@@ -281,6 +659,45 @@ async function insertMany(handle, docs) {
281
659
  return parsed;
282
660
  }
283
661
 
662
+ // src/crud/update.ts
663
+ var import_zod5 = require("zod");
664
+ async function updateOne(handle, filter, update, options) {
665
+ return await handle.native.updateOne(filter, update, options);
666
+ }
667
+ async function updateMany(handle, filter, update, options) {
668
+ return await handle.native.updateMany(filter, update, options);
669
+ }
670
+ async function findOneAndUpdate(handle, filter, update, options) {
671
+ const driverOptions = {
672
+ returnDocument: options?.returnDocument ?? "after",
673
+ includeResultMetadata: false
674
+ };
675
+ if (options?.upsert !== void 0) {
676
+ driverOptions["upsert"] = options.upsert;
677
+ }
678
+ const result = await handle.native.findOneAndUpdate(
679
+ // biome-ignore lint/suspicious/noExplicitAny: TypedFilter intersection type is not directly assignable to MongoDB's Filter
680
+ filter,
681
+ // biome-ignore lint/suspicious/noExplicitAny: TypedUpdateFilter intersection type is not directly assignable to MongoDB's UpdateFilter
682
+ update,
683
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic options object is not assignable to driver's FindOneAndUpdateOptions under exactOptionalPropertyTypes
684
+ driverOptions
685
+ );
686
+ if (!result) return null;
687
+ const mode = options?.validate !== void 0 ? options.validate : handle.definition.options.validation;
688
+ if (mode === false || mode === "passthrough") {
689
+ return result;
690
+ }
691
+ try {
692
+ return handle.definition.schema.parse(result);
693
+ } catch (err) {
694
+ if (err instanceof import_zod5.z.ZodError) {
695
+ throw new ZodmonValidationError(handle.definition.name, err);
696
+ }
697
+ throw err;
698
+ }
699
+ }
700
+
284
701
  // src/client/handle.ts
285
702
  var CollectionHandle = class {
286
703
  /** The collection definition containing schema, name, and index metadata. */
@@ -402,6 +819,169 @@ var CollectionHandle = class {
402
819
  find(filter, options) {
403
820
  return find(this, filter, options);
404
821
  }
822
+ /**
823
+ * Update a single document matching the filter.
824
+ *
825
+ * Applies the update operators to the first document that matches the filter.
826
+ * Does not validate the update against the Zod schema — validation happens
827
+ * at the field-operator level through {@link TypedUpdateFilter}.
828
+ *
829
+ * @param filter - Type-safe filter to match documents.
830
+ * @param update - Type-safe update operators to apply.
831
+ * @param options - Optional settings such as `upsert`.
832
+ * @returns The MongoDB `UpdateResult` with match/modify counts.
833
+ *
834
+ * @example
835
+ * ```ts
836
+ * const users = db.use(Users)
837
+ * const result = await users.updateOne({ name: 'Ada' }, { $set: { role: 'admin' } })
838
+ * console.log(result.modifiedCount) // 1
839
+ * ```
840
+ */
841
+ async updateOne(filter, update, options) {
842
+ return await updateOne(this, filter, update, options);
843
+ }
844
+ /**
845
+ * Update all documents matching the filter.
846
+ *
847
+ * Applies the update operators to every document that matches the filter.
848
+ * Does not validate the update against the Zod schema — validation happens
849
+ * at the field-operator level through {@link TypedUpdateFilter}.
850
+ *
851
+ * @param filter - Type-safe filter to match documents.
852
+ * @param update - Type-safe update operators to apply.
853
+ * @param options - Optional settings such as `upsert`.
854
+ * @returns The MongoDB `UpdateResult` with match/modify counts.
855
+ *
856
+ * @example
857
+ * ```ts
858
+ * const users = db.use(Users)
859
+ * const result = await users.updateMany({ role: 'guest' }, { $set: { role: 'user' } })
860
+ * console.log(result.modifiedCount) // number of guests promoted
861
+ * ```
862
+ */
863
+ async updateMany(filter, update, options) {
864
+ return await updateMany(this, filter, update, options);
865
+ }
866
+ /**
867
+ * Find a single document matching the filter, apply an update, and return the document.
868
+ *
869
+ * By default, returns the document **after** the update is applied. Set
870
+ * `returnDocument: 'before'` to get the pre-update snapshot. The returned
871
+ * document is validated against the collection's Zod schema using the same
872
+ * resolution logic as {@link findOne}.
873
+ *
874
+ * @param filter - Type-safe filter to match documents.
875
+ * @param update - Type-safe update operators to apply.
876
+ * @param options - Optional settings: `returnDocument`, `upsert`, `validate`.
877
+ * @returns The matched document (before or after update), or `null` if no document matches.
878
+ * @throws {ZodmonValidationError} When the returned document fails schema validation in strict mode.
879
+ *
880
+ * @example
881
+ * ```ts
882
+ * const users = db.use(Users)
883
+ * const user = await users.findOneAndUpdate(
884
+ * { name: 'Ada' },
885
+ * { $set: { role: 'admin' } },
886
+ * )
887
+ * if (user) console.log(user.role) // 'admin' (returned after update)
888
+ * ```
889
+ */
890
+ async findOneAndUpdate(filter, update, options) {
891
+ return await findOneAndUpdate(this, filter, update, options);
892
+ }
893
+ /**
894
+ * Delete a single document matching the filter.
895
+ *
896
+ * Removes the first document that matches the filter from the collection.
897
+ * No validation is performed — the document is deleted directly through
898
+ * the MongoDB driver.
899
+ *
900
+ * @param filter - Type-safe filter to match documents.
901
+ * @returns The MongoDB `DeleteResult` with the deleted count.
902
+ *
903
+ * @example
904
+ * ```ts
905
+ * const users = db.use(Users)
906
+ * const result = await users.deleteOne({ name: 'Ada' })
907
+ * console.log(result.deletedCount) // 1
908
+ * ```
909
+ */
910
+ async deleteOne(filter) {
911
+ return await deleteOne(this, filter);
912
+ }
913
+ /**
914
+ * Delete all documents matching the filter.
915
+ *
916
+ * Removes every document that matches the filter from the collection.
917
+ * No validation is performed — documents are deleted directly through
918
+ * the MongoDB driver.
919
+ *
920
+ * @param filter - Type-safe filter to match documents.
921
+ * @returns The MongoDB `DeleteResult` with the deleted count.
922
+ *
923
+ * @example
924
+ * ```ts
925
+ * const users = db.use(Users)
926
+ * const result = await users.deleteMany({ role: 'guest' })
927
+ * console.log(result.deletedCount) // number of guests removed
928
+ * ```
929
+ */
930
+ async deleteMany(filter) {
931
+ return await deleteMany(this, filter);
932
+ }
933
+ /**
934
+ * Find a single document matching the filter, delete it, and return the document.
935
+ *
936
+ * Returns the deleted document, or `null` if no document matches the filter.
937
+ * The returned document is validated against the collection's Zod schema
938
+ * using the same resolution logic as {@link findOne}.
939
+ *
940
+ * @param filter - Type-safe filter to match documents.
941
+ * @param options - Optional settings: `validate`.
942
+ * @returns The deleted document, or `null` if no document matches.
943
+ * @throws {ZodmonValidationError} When the returned document fails schema validation in strict mode.
944
+ *
945
+ * @example
946
+ * ```ts
947
+ * const users = db.use(Users)
948
+ * const user = await users.findOneAndDelete({ name: 'Ada' })
949
+ * if (user) console.log(user.name) // 'Ada' (the deleted document)
950
+ * ```
951
+ */
952
+ async findOneAndDelete(filter, options) {
953
+ return await findOneAndDelete(this, filter, options);
954
+ }
955
+ /**
956
+ * Synchronize the indexes declared in this collection's schema with MongoDB.
957
+ *
958
+ * Compares the desired indexes (from field-level `.index()` / `.unique()` /
959
+ * `.text()` / `.expireAfter()` and compound `indexes` in collection options)
960
+ * with the indexes that currently exist in MongoDB, then creates, drops, or
961
+ * reports differences depending on the options.
962
+ *
963
+ * @param options - Optional sync behavior (dryRun, dropOrphaned).
964
+ * @returns A summary of created, dropped, skipped, and stale indexes.
965
+ *
966
+ * @example
967
+ * ```ts
968
+ * const users = db.use(Users)
969
+ * const result = await users.syncIndexes()
970
+ * console.log('Created:', result.created)
971
+ * console.log('Stale:', result.stale.map(s => s.name))
972
+ * ```
973
+ *
974
+ * @example
975
+ * ```ts
976
+ * // Dry run to preview changes without modifying the database
977
+ * const diff = await users.syncIndexes({ dryRun: true })
978
+ * console.log('Would create:', diff.created)
979
+ * console.log('Would drop:', diff.dropped)
980
+ * ```
981
+ */
982
+ async syncIndexes(options) {
983
+ return await syncIndexes(this, options);
984
+ }
405
985
  };
406
986
 
407
987
  // src/client/client.ts
@@ -411,7 +991,7 @@ var Database = class {
411
991
  /** Registered collection definitions, keyed by name. Used by syncIndexes(). */
412
992
  _collections = /* @__PURE__ */ new Map();
413
993
  constructor(uri, dbName, options) {
414
- this._client = new import_mongodb.MongoClient(uri, options);
994
+ this._client = new import_mongodb2.MongoClient(uri, options);
415
995
  this._db = this._client.db(dbName);
416
996
  }
417
997
  /**
@@ -427,19 +1007,42 @@ var Database = class {
427
1007
  */
428
1008
  use(def) {
429
1009
  this._collections.set(def.name, def);
430
- const native = this._db.collection(def.name);
1010
+ const native = this._db.collection(
1011
+ def.name
1012
+ );
431
1013
  return new CollectionHandle(
432
1014
  def,
433
1015
  native
434
1016
  );
435
1017
  }
436
1018
  /**
437
- * Synchronize indexes defined in registered collections with MongoDB.
1019
+ * Synchronize indexes for all registered collections with MongoDB.
1020
+ *
1021
+ * Iterates every collection registered via {@link use} and calls
1022
+ * {@link syncIndexes} on each one. Returns a record keyed by collection
1023
+ * name with the sync result for each.
438
1024
  *
439
- * Stub full implementation in TASK-92.
1025
+ * @param options - Optional sync behavior (dryRun, dropOrphaned).
1026
+ * @returns A record mapping collection names to their sync results.
1027
+ *
1028
+ * @example
1029
+ * ```ts
1030
+ * const db = createClient('mongodb://localhost:27017', 'myapp')
1031
+ * db.use(Users)
1032
+ * db.use(Posts)
1033
+ * const results = await db.syncIndexes()
1034
+ * console.log(results['users'].created) // ['email_1']
1035
+ * console.log(results['posts'].created) // ['title_1']
1036
+ * ```
440
1037
  */
441
- syncIndexes() {
442
- return Promise.resolve();
1038
+ async syncIndexes(options) {
1039
+ const results = {};
1040
+ for (const [name, def] of this._collections) {
1041
+ const native = this._db.collection(name);
1042
+ const handle = new CollectionHandle(def, native);
1043
+ results[name] = await syncIndexes(handle, options);
1044
+ }
1045
+ return results;
443
1046
  }
444
1047
  /**
445
1048
  * Execute a function within a MongoDB transaction with auto-commit/rollback.
@@ -479,14 +1082,14 @@ function createClient(uri, dbNameOrOptions, maybeOptions) {
479
1082
  }
480
1083
 
481
1084
  // src/collection/collection.ts
482
- var import_mongodb3 = require("mongodb");
483
- var import_zod7 = require("zod");
1085
+ var import_mongodb4 = require("mongodb");
1086
+ var import_zod9 = require("zod");
484
1087
 
485
1088
  // src/schema/extensions.ts
486
- var import_zod5 = require("zod");
1089
+ var import_zod7 = require("zod");
487
1090
 
488
1091
  // src/schema/ref.ts
489
- var import_zod4 = require("zod");
1092
+ var import_zod6 = require("zod");
490
1093
  var refMetadata = /* @__PURE__ */ new WeakMap();
491
1094
  function getRefMetadata(schema) {
492
1095
  if (typeof schema !== "object" || schema === null) return void 0;
@@ -494,7 +1097,7 @@ function getRefMetadata(schema) {
494
1097
  }
495
1098
  var REF_GUARD = /* @__PURE__ */ Symbol.for("zodmon_ref");
496
1099
  function installRefExtension() {
497
- const proto = import_zod4.z.ZodType.prototype;
1100
+ const proto = import_zod6.z.ZodType.prototype;
498
1101
  if (REF_GUARD in proto) return;
499
1102
  Object.defineProperty(proto, "ref", {
500
1103
  value(collection2) {
@@ -521,7 +1124,7 @@ function getIndexMetadata(schema) {
521
1124
  }
522
1125
  var GUARD = /* @__PURE__ */ Symbol.for("zodmon_extensions");
523
1126
  function installExtensions() {
524
- const proto = import_zod5.z.ZodType.prototype;
1127
+ const proto = import_zod7.z.ZodType.prototype;
525
1128
  if (GUARD in proto) return;
526
1129
  Object.defineProperty(proto, "index", {
527
1130
  /**
@@ -619,14 +1222,14 @@ function installExtensions() {
619
1222
  installExtensions();
620
1223
 
621
1224
  // src/schema/object-id.ts
622
- var import_mongodb2 = require("mongodb");
623
- var import_zod6 = require("zod");
1225
+ var import_mongodb3 = require("mongodb");
1226
+ var import_zod8 = require("zod");
624
1227
  var OBJECT_ID_HEX = /^[a-f\d]{24}$/i;
625
1228
  function objectId() {
626
- return import_zod6.z.custom((val) => {
627
- if (val instanceof import_mongodb2.ObjectId) return true;
1229
+ return import_zod8.z.custom((val) => {
1230
+ if (val instanceof import_mongodb3.ObjectId) return true;
628
1231
  return typeof val === "string" && OBJECT_ID_HEX.test(val);
629
- }, "Invalid ObjectId").transform((val) => val instanceof import_mongodb2.ObjectId ? val : import_mongodb2.ObjectId.createFromHexString(val));
1232
+ }, "Invalid ObjectId").transform((val) => val instanceof import_mongodb3.ObjectId ? val : import_mongodb3.ObjectId.createFromHexString(val));
630
1233
  }
631
1234
 
632
1235
  // src/collection/collection.ts
@@ -641,8 +1244,8 @@ function extractFieldIndexes(shape) {
641
1244
  return result;
642
1245
  }
643
1246
  function collection(name, shape, options) {
644
- const resolvedShape = "_id" in shape ? shape : { _id: objectId().default(() => new import_mongodb3.ObjectId()), ...shape };
645
- const schema = import_zod7.z.object(resolvedShape);
1247
+ const resolvedShape = "_id" in shape ? shape : { _id: objectId().default(() => new import_mongodb4.ObjectId()), ...shape };
1248
+ const schema = import_zod9.z.object(resolvedShape);
646
1249
  const fieldIndexes = extractFieldIndexes(shape);
647
1250
  const { indexes: compoundIndexes, validation, ...rest } = options ?? {};
648
1251
  return {
@@ -654,6 +1257,8 @@ function collection(name, shape, options) {
654
1257
  schema,
655
1258
  shape,
656
1259
  fieldIndexes,
1260
+ // Safe cast: compoundIndexes is TIndexes at runtime (or an empty array when
1261
+ // no options provided). The spread into [...TIndexes] preserves the tuple type.
657
1262
  compoundIndexes: compoundIndexes ?? [],
658
1263
  options: {
659
1264
  validation: validation ?? "strict",
@@ -678,12 +1283,29 @@ var IndexBuilder = class _IndexBuilder {
678
1283
  options
679
1284
  });
680
1285
  }
1286
+ // Safe cast: _clone returns IndexBuilder<TKeys> but `this` may carry an
1287
+ // intersection from .name(). The cast is safe because _clone preserves all fields.
681
1288
  unique() {
682
1289
  return this._clone({ ...this.options, unique: true });
683
1290
  }
1291
+ // Safe cast: same reasoning as unique().
684
1292
  sparse() {
685
1293
  return this._clone({ ...this.options, sparse: true });
686
1294
  }
1295
+ /**
1296
+ * Set a custom name for this index, preserving the literal type.
1297
+ *
1298
+ * The returned builder carries the literal name type via an intersection,
1299
+ * enabling type-safe `.hint()` on cursors that only accepts declared names.
1300
+ *
1301
+ * @param name - The index name.
1302
+ * @returns A new IndexBuilder with the name recorded at the type level.
1303
+ *
1304
+ * @example
1305
+ * ```ts
1306
+ * index({ email: 1, role: -1 }).name('email_role_idx')
1307
+ * ```
1308
+ */
687
1309
  name(name) {
688
1310
  return this._clone({ ...this.options, name });
689
1311
  }
@@ -693,14 +1315,14 @@ function index(fields) {
693
1315
  }
694
1316
 
695
1317
  // src/helpers/oid.ts
696
- var import_mongodb4 = require("mongodb");
1318
+ var import_mongodb5 = require("mongodb");
697
1319
  function oid(value) {
698
- if (value === void 0) return new import_mongodb4.ObjectId();
699
- if (value instanceof import_mongodb4.ObjectId) return value;
700
- return import_mongodb4.ObjectId.createFromHexString(value);
1320
+ if (value === void 0) return new import_mongodb5.ObjectId();
1321
+ if (value instanceof import_mongodb5.ObjectId) return value;
1322
+ return import_mongodb5.ObjectId.createFromHexString(value);
701
1323
  }
702
1324
  function isOid(value) {
703
- return value instanceof import_mongodb4.ObjectId;
1325
+ return value instanceof import_mongodb5.ObjectId;
704
1326
  }
705
1327
 
706
1328
  // src/query/operators.ts
@@ -723,8 +1345,28 @@ var $or = (...filters) => ({ $or: filters });
723
1345
  var $and = (...filters) => ({ $and: filters });
724
1346
  var $nor = (...filters) => ({ $nor: filters });
725
1347
  var raw = (filter) => filter;
1348
+
1349
+ // src/query/namespace.ts
1350
+ var $ = {
1351
+ eq: $eq,
1352
+ ne: $ne,
1353
+ gt: $gt,
1354
+ gte: $gte,
1355
+ lt: $lt,
1356
+ lte: $lte,
1357
+ in: $in,
1358
+ nin: $nin,
1359
+ exists: $exists,
1360
+ regex: $regex,
1361
+ not: $not,
1362
+ or: $or,
1363
+ and: $and,
1364
+ nor: $nor,
1365
+ raw
1366
+ };
726
1367
  // Annotate the CommonJS export names for ESM import in node:
727
1368
  0 && (module.exports = {
1369
+ $,
728
1370
  $and,
729
1371
  $eq,
730
1372
  $exists,
@@ -745,23 +1387,34 @@ var raw = (filter) => filter;
745
1387
  TypedFindCursor,
746
1388
  ZodmonNotFoundError,
747
1389
  ZodmonValidationError,
1390
+ checkUnindexedFields,
748
1391
  collection,
749
1392
  createClient,
1393
+ deleteMany,
1394
+ deleteOne,
1395
+ extractComparableOptions,
750
1396
  extractDbName,
751
1397
  extractFieldIndexes,
752
1398
  find,
753
1399
  findOne,
1400
+ findOneAndDelete,
1401
+ findOneAndUpdate,
754
1402
  findOneOrThrow,
1403
+ generateIndexName,
755
1404
  getIndexMetadata,
756
1405
  getRefMetadata,
757
1406
  index,
758
1407
  insertMany,
759
1408
  insertOne,
760
- installExtensions,
761
- installRefExtension,
762
1409
  isOid,
763
1410
  objectId,
764
1411
  oid,
765
- raw
1412
+ raw,
1413
+ serializeIndexKey,
1414
+ syncIndexes,
1415
+ toCompoundIndexSpec,
1416
+ toFieldIndexSpec,
1417
+ updateMany,
1418
+ updateOne
766
1419
  });
767
1420
  //# sourceMappingURL=index.cjs.map