@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.js CHANGED
@@ -1,6 +1,168 @@
1
1
  // src/client/client.ts
2
2
  import { MongoClient } from "mongodb";
3
3
 
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
+ }
27
+
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
+ }
41
+ }
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
+
4
166
  // src/crud/delete.ts
5
167
  import { z } from "zod";
6
168
 
@@ -64,6 +226,29 @@ var ZodmonNotFoundError = class extends Error {
64
226
  }
65
227
  };
66
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
+
67
252
  // src/query/cursor.ts
68
253
  import { z as z2 } from "zod";
69
254
 
@@ -81,8 +266,8 @@ function deserializeValue(value) {
81
266
  }
82
267
  return value;
83
268
  }
84
- function encodeCursor(doc, sortKeys, direction) {
85
- const values = sortKeys.map(([field]) => serializeValue(doc[field]));
269
+ function encodeCursor(doc, sortKeys2, direction) {
270
+ const values = sortKeys2.map(([field]) => serializeValue(doc[field]));
86
271
  return btoa(JSON.stringify([direction, ...values]));
87
272
  }
88
273
  function decodeCursor(cursor) {
@@ -101,14 +286,14 @@ function decodeCursor(cursor) {
101
286
  }
102
287
  return { direction, values: rawValues.map(deserializeValue) };
103
288
  }
104
- function buildCursorFilter(sortKeys, values, isBackward) {
289
+ function buildCursorFilter(sortKeys2, values, isBackward) {
105
290
  const clauses = [];
106
- for (let i = 0; i < sortKeys.length; i++) {
291
+ for (let i = 0; i < sortKeys2.length; i++) {
107
292
  const clause = {};
108
293
  for (let j = 0; j < i; j++) {
109
- clause[sortKeys[j][0]] = values[j];
294
+ clause[sortKeys2[j][0]] = values[j];
110
295
  }
111
- const [field, direction] = sortKeys[i];
296
+ const [field, direction] = sortKeys2[i];
112
297
  const isAsc = direction === 1;
113
298
  const op = isAsc !== isBackward ? "$gt" : "$lt";
114
299
  if (values[i] === null) {
@@ -204,14 +389,37 @@ var TypedFindCursor = class {
204
389
  this.cursor.limit(n);
205
390
  return this;
206
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
+ }
207
415
  async paginate(opts) {
208
416
  const sortRecord = this.sortSpec ? this.sortSpec : null;
209
- const sortKeys = resolveSortKeys(sortRecord);
210
- const sort = Object.fromEntries(sortKeys);
417
+ const sortKeys2 = resolveSortKeys(sortRecord);
418
+ const sort = Object.fromEntries(sortKeys2);
211
419
  if ("page" in opts) {
212
- return await this.offsetPaginate(sortKeys, sort, opts);
420
+ return await this.offsetPaginate(sortKeys2, sort, opts);
213
421
  }
214
- return await this.cursorPaginate(sortKeys, sort, opts);
422
+ return await this.cursorPaginate(sortKeys2, sort, opts);
215
423
  }
216
424
  /** @internal Offset pagination implementation. */
217
425
  async offsetPaginate(_sortKeys, sort, opts) {
@@ -232,16 +440,16 @@ var TypedFindCursor = class {
232
440
  };
233
441
  }
234
442
  /** @internal Cursor pagination implementation. */
235
- async cursorPaginate(sortKeys, sort, opts) {
443
+ async cursorPaginate(sortKeys2, sort, opts) {
236
444
  let isBackward = false;
237
445
  let combinedFilter = this.filter;
238
446
  if (opts.cursor) {
239
447
  const decoded = decodeCursor(opts.cursor);
240
448
  isBackward = decoded.direction === "b";
241
- const cursorFilter = buildCursorFilter(sortKeys, decoded.values, isBackward);
449
+ const cursorFilter = buildCursorFilter(sortKeys2, decoded.values, isBackward);
242
450
  combinedFilter = this.filter && Object.keys(this.filter).length > 0 ? { $and: [this.filter, cursorFilter] } : cursorFilter;
243
451
  }
244
- const effectiveSort = isBackward ? Object.fromEntries(sortKeys.map(([f, d]) => [f, d === 1 ? -1 : 1])) : sort;
452
+ const effectiveSort = isBackward ? Object.fromEntries(sortKeys2.map(([f, d]) => [f, d === 1 ? -1 : 1])) : sort;
245
453
  const raw2 = await this.nativeCollection.find(combinedFilter).sort(effectiveSort).limit(opts.limit + 1).toArray();
246
454
  const hasMore = raw2.length > opts.limit;
247
455
  if (hasMore) raw2.pop();
@@ -251,8 +459,8 @@ var TypedFindCursor = class {
251
459
  docs,
252
460
  hasNext: isBackward ? true : hasMore,
253
461
  hasPrev: isBackward ? hasMore : opts.cursor != null,
254
- startCursor: docs.length > 0 ? encodeCursor(docs[0], sortKeys, "b") : null,
255
- endCursor: docs.length > 0 ? encodeCursor(docs[docs.length - 1], sortKeys, "f") : 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
256
464
  };
257
465
  }
258
466
  /**
@@ -312,6 +520,7 @@ var TypedFindCursor = class {
312
520
 
313
521
  // src/crud/find.ts
314
522
  async function findOne(handle, filter, options) {
523
+ checkUnindexedFields(handle.definition, filter);
315
524
  const findOptions = options?.project ? { projection: options.project } : void 0;
316
525
  const raw2 = await handle.native.findOne(filter, findOptions);
317
526
  if (!raw2) return null;
@@ -336,6 +545,7 @@ async function findOneOrThrow(handle, filter, options) {
336
545
  return doc;
337
546
  }
338
547
  function find(handle, filter, options) {
548
+ checkUnindexedFields(handle.definition, filter);
339
549
  const raw2 = handle.native.find(filter);
340
550
  const cursor = raw2;
341
551
  const mode = options?.validate !== void 0 ? options.validate : handle.definition.options.validation;
@@ -667,6 +877,36 @@ var CollectionHandle = class {
667
877
  async findOneAndDelete(filter, options) {
668
878
  return await findOneAndDelete(this, filter, options);
669
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
+ }
670
910
  };
671
911
 
672
912
  // src/client/client.ts
@@ -692,19 +932,42 @@ var Database = class {
692
932
  */
693
933
  use(def) {
694
934
  this._collections.set(def.name, def);
695
- const native = this._db.collection(def.name);
935
+ const native = this._db.collection(
936
+ def.name
937
+ );
696
938
  return new CollectionHandle(
697
939
  def,
698
940
  native
699
941
  );
700
942
  }
701
943
  /**
702
- * 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.
703
952
  *
704
- * 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
+ * ```
705
962
  */
706
- syncIndexes() {
707
- 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;
708
971
  }
709
972
  /**
710
973
  * Execute a function within a MongoDB transaction with auto-commit/rollback.
@@ -919,6 +1182,8 @@ function collection(name, shape, options) {
919
1182
  schema,
920
1183
  shape,
921
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.
922
1187
  compoundIndexes: compoundIndexes ?? [],
923
1188
  options: {
924
1189
  validation: validation ?? "strict",
@@ -943,12 +1208,29 @@ var IndexBuilder = class _IndexBuilder {
943
1208
  options
944
1209
  });
945
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.
946
1213
  unique() {
947
1214
  return this._clone({ ...this.options, unique: true });
948
1215
  }
1216
+ // Safe cast: same reasoning as unique().
949
1217
  sparse() {
950
1218
  return this._clone({ ...this.options, sparse: true });
951
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
+ */
952
1234
  name(name) {
953
1235
  return this._clone({ ...this.options, name });
954
1236
  }
@@ -1029,10 +1311,12 @@ export {
1029
1311
  TypedFindCursor,
1030
1312
  ZodmonNotFoundError,
1031
1313
  ZodmonValidationError,
1314
+ checkUnindexedFields,
1032
1315
  collection,
1033
1316
  createClient,
1034
1317
  deleteMany,
1035
1318
  deleteOne,
1319
+ extractComparableOptions,
1036
1320
  extractDbName,
1037
1321
  extractFieldIndexes,
1038
1322
  find,
@@ -1040,6 +1324,7 @@ export {
1040
1324
  findOneAndDelete,
1041
1325
  findOneAndUpdate,
1042
1326
  findOneOrThrow,
1327
+ generateIndexName,
1043
1328
  getIndexMetadata,
1044
1329
  getRefMetadata,
1045
1330
  index,
@@ -1049,6 +1334,10 @@ export {
1049
1334
  objectId,
1050
1335
  oid,
1051
1336
  raw,
1337
+ serializeIndexKey,
1338
+ syncIndexes,
1339
+ toCompoundIndexSpec,
1340
+ toFieldIndexSpec,
1052
1341
  updateMany,
1053
1342
  updateOne
1054
1343
  };