@zodmon/core 0.7.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
@@ -41,10 +41,12 @@ __export(index_exports, {
41
41
  TypedFindCursor: () => TypedFindCursor,
42
42
  ZodmonNotFoundError: () => ZodmonNotFoundError,
43
43
  ZodmonValidationError: () => ZodmonValidationError,
44
+ checkUnindexedFields: () => checkUnindexedFields,
44
45
  collection: () => collection,
45
46
  createClient: () => createClient,
46
47
  deleteMany: () => deleteMany,
47
48
  deleteOne: () => deleteOne,
49
+ extractComparableOptions: () => extractComparableOptions,
48
50
  extractDbName: () => extractDbName,
49
51
  extractFieldIndexes: () => extractFieldIndexes,
50
52
  find: () => find,
@@ -52,6 +54,7 @@ __export(index_exports, {
52
54
  findOneAndDelete: () => findOneAndDelete,
53
55
  findOneAndUpdate: () => findOneAndUpdate,
54
56
  findOneOrThrow: () => findOneOrThrow,
57
+ generateIndexName: () => generateIndexName,
55
58
  getIndexMetadata: () => getIndexMetadata,
56
59
  getRefMetadata: () => getRefMetadata,
57
60
  index: () => index,
@@ -61,6 +64,10 @@ __export(index_exports, {
61
64
  objectId: () => objectId,
62
65
  oid: () => oid,
63
66
  raw: () => raw,
67
+ serializeIndexKey: () => serializeIndexKey,
68
+ syncIndexes: () => syncIndexes,
69
+ toCompoundIndexSpec: () => toCompoundIndexSpec,
70
+ toFieldIndexSpec: () => toFieldIndexSpec,
64
71
  updateMany: () => updateMany,
65
72
  updateOne: () => updateOne
66
73
  });
@@ -69,6 +76,168 @@ module.exports = __toCommonJS(index_exports);
69
76
  // src/client/client.ts
70
77
  var import_mongodb2 = require("mongodb");
71
78
 
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
+ }
102
+
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
+ }
116
+ }
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
+
72
241
  // src/crud/delete.ts
73
242
  var import_zod = require("zod");
74
243
 
@@ -132,6 +301,29 @@ var ZodmonNotFoundError = class extends Error {
132
301
  }
133
302
  };
134
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
+
135
327
  // src/query/cursor.ts
136
328
  var import_zod2 = require("zod");
137
329
 
@@ -149,8 +341,8 @@ function deserializeValue(value) {
149
341
  }
150
342
  return value;
151
343
  }
152
- function encodeCursor(doc, sortKeys, direction) {
153
- const values = sortKeys.map(([field]) => serializeValue(doc[field]));
344
+ function encodeCursor(doc, sortKeys2, direction) {
345
+ const values = sortKeys2.map(([field]) => serializeValue(doc[field]));
154
346
  return btoa(JSON.stringify([direction, ...values]));
155
347
  }
156
348
  function decodeCursor(cursor) {
@@ -169,14 +361,14 @@ function decodeCursor(cursor) {
169
361
  }
170
362
  return { direction, values: rawValues.map(deserializeValue) };
171
363
  }
172
- function buildCursorFilter(sortKeys, values, isBackward) {
364
+ function buildCursorFilter(sortKeys2, values, isBackward) {
173
365
  const clauses = [];
174
- for (let i = 0; i < sortKeys.length; i++) {
366
+ for (let i = 0; i < sortKeys2.length; i++) {
175
367
  const clause = {};
176
368
  for (let j = 0; j < i; j++) {
177
- clause[sortKeys[j][0]] = values[j];
369
+ clause[sortKeys2[j][0]] = values[j];
178
370
  }
179
- const [field, direction] = sortKeys[i];
371
+ const [field, direction] = sortKeys2[i];
180
372
  const isAsc = direction === 1;
181
373
  const op = isAsc !== isBackward ? "$gt" : "$lt";
182
374
  if (values[i] === null) {
@@ -272,14 +464,37 @@ var TypedFindCursor = class {
272
464
  this.cursor.limit(n);
273
465
  return this;
274
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
+ }
275
490
  async paginate(opts) {
276
491
  const sortRecord = this.sortSpec ? this.sortSpec : null;
277
- const sortKeys = resolveSortKeys(sortRecord);
278
- const sort = Object.fromEntries(sortKeys);
492
+ const sortKeys2 = resolveSortKeys(sortRecord);
493
+ const sort = Object.fromEntries(sortKeys2);
279
494
  if ("page" in opts) {
280
- return await this.offsetPaginate(sortKeys, sort, opts);
495
+ return await this.offsetPaginate(sortKeys2, sort, opts);
281
496
  }
282
- return await this.cursorPaginate(sortKeys, sort, opts);
497
+ return await this.cursorPaginate(sortKeys2, sort, opts);
283
498
  }
284
499
  /** @internal Offset pagination implementation. */
285
500
  async offsetPaginate(_sortKeys, sort, opts) {
@@ -300,16 +515,16 @@ var TypedFindCursor = class {
300
515
  };
301
516
  }
302
517
  /** @internal Cursor pagination implementation. */
303
- async cursorPaginate(sortKeys, sort, opts) {
518
+ async cursorPaginate(sortKeys2, sort, opts) {
304
519
  let isBackward = false;
305
520
  let combinedFilter = this.filter;
306
521
  if (opts.cursor) {
307
522
  const decoded = decodeCursor(opts.cursor);
308
523
  isBackward = decoded.direction === "b";
309
- const cursorFilter = buildCursorFilter(sortKeys, decoded.values, isBackward);
524
+ const cursorFilter = buildCursorFilter(sortKeys2, decoded.values, isBackward);
310
525
  combinedFilter = this.filter && Object.keys(this.filter).length > 0 ? { $and: [this.filter, cursorFilter] } : cursorFilter;
311
526
  }
312
- const effectiveSort = isBackward ? Object.fromEntries(sortKeys.map(([f, d]) => [f, d === 1 ? -1 : 1])) : sort;
527
+ const effectiveSort = isBackward ? Object.fromEntries(sortKeys2.map(([f, d]) => [f, d === 1 ? -1 : 1])) : sort;
313
528
  const raw2 = await this.nativeCollection.find(combinedFilter).sort(effectiveSort).limit(opts.limit + 1).toArray();
314
529
  const hasMore = raw2.length > opts.limit;
315
530
  if (hasMore) raw2.pop();
@@ -319,8 +534,8 @@ var TypedFindCursor = class {
319
534
  docs,
320
535
  hasNext: isBackward ? true : hasMore,
321
536
  hasPrev: isBackward ? hasMore : opts.cursor != null,
322
- startCursor: docs.length > 0 ? encodeCursor(docs[0], sortKeys, "b") : null,
323
- endCursor: docs.length > 0 ? encodeCursor(docs[docs.length - 1], sortKeys, "f") : 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
324
539
  };
325
540
  }
326
541
  /**
@@ -380,6 +595,7 @@ var TypedFindCursor = class {
380
595
 
381
596
  // src/crud/find.ts
382
597
  async function findOne(handle, filter, options) {
598
+ checkUnindexedFields(handle.definition, filter);
383
599
  const findOptions = options?.project ? { projection: options.project } : void 0;
384
600
  const raw2 = await handle.native.findOne(filter, findOptions);
385
601
  if (!raw2) return null;
@@ -404,6 +620,7 @@ async function findOneOrThrow(handle, filter, options) {
404
620
  return doc;
405
621
  }
406
622
  function find(handle, filter, options) {
623
+ checkUnindexedFields(handle.definition, filter);
407
624
  const raw2 = handle.native.find(filter);
408
625
  const cursor = raw2;
409
626
  const mode = options?.validate !== void 0 ? options.validate : handle.definition.options.validation;
@@ -735,6 +952,36 @@ var CollectionHandle = class {
735
952
  async findOneAndDelete(filter, options) {
736
953
  return await findOneAndDelete(this, filter, options);
737
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
+ }
738
985
  };
739
986
 
740
987
  // src/client/client.ts
@@ -760,19 +1007,42 @@ var Database = class {
760
1007
  */
761
1008
  use(def) {
762
1009
  this._collections.set(def.name, def);
763
- const native = this._db.collection(def.name);
1010
+ const native = this._db.collection(
1011
+ def.name
1012
+ );
764
1013
  return new CollectionHandle(
765
1014
  def,
766
1015
  native
767
1016
  );
768
1017
  }
769
1018
  /**
770
- * 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.
1024
+ *
1025
+ * @param options - Optional sync behavior (dryRun, dropOrphaned).
1026
+ * @returns A record mapping collection names to their sync results.
771
1027
  *
772
- * Stub — full implementation in TASK-92.
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
+ * ```
773
1037
  */
774
- syncIndexes() {
775
- 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;
776
1046
  }
777
1047
  /**
778
1048
  * Execute a function within a MongoDB transaction with auto-commit/rollback.
@@ -987,6 +1257,8 @@ function collection(name, shape, options) {
987
1257
  schema,
988
1258
  shape,
989
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.
990
1262
  compoundIndexes: compoundIndexes ?? [],
991
1263
  options: {
992
1264
  validation: validation ?? "strict",
@@ -1011,12 +1283,29 @@ var IndexBuilder = class _IndexBuilder {
1011
1283
  options
1012
1284
  });
1013
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.
1014
1288
  unique() {
1015
1289
  return this._clone({ ...this.options, unique: true });
1016
1290
  }
1291
+ // Safe cast: same reasoning as unique().
1017
1292
  sparse() {
1018
1293
  return this._clone({ ...this.options, sparse: true });
1019
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
+ */
1020
1309
  name(name) {
1021
1310
  return this._clone({ ...this.options, name });
1022
1311
  }
@@ -1098,10 +1387,12 @@ var $ = {
1098
1387
  TypedFindCursor,
1099
1388
  ZodmonNotFoundError,
1100
1389
  ZodmonValidationError,
1390
+ checkUnindexedFields,
1101
1391
  collection,
1102
1392
  createClient,
1103
1393
  deleteMany,
1104
1394
  deleteOne,
1395
+ extractComparableOptions,
1105
1396
  extractDbName,
1106
1397
  extractFieldIndexes,
1107
1398
  find,
@@ -1109,6 +1400,7 @@ var $ = {
1109
1400
  findOneAndDelete,
1110
1401
  findOneAndUpdate,
1111
1402
  findOneOrThrow,
1403
+ generateIndexName,
1112
1404
  getIndexMetadata,
1113
1405
  getRefMetadata,
1114
1406
  index,
@@ -1118,6 +1410,10 @@ var $ = {
1118
1410
  objectId,
1119
1411
  oid,
1120
1412
  raw,
1413
+ serializeIndexKey,
1414
+ syncIndexes,
1415
+ toCompoundIndexSpec,
1416
+ toFieldIndexSpec,
1121
1417
  updateMany,
1122
1418
  updateOne
1123
1419
  });