@atscript/db-mongo 0.1.101 → 0.1.102

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/agg.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_mongo_filter = require("./mongo-filter-AihWWQXp.cjs");
2
+ const require_mongo_filter = require("./mongo-filter-1EpqdD-T.cjs");
3
3
  let _atscript_db_agg = require("@atscript/db/agg");
4
4
  //#region src/agg.ts
5
5
  /** Simple accumulators that map directly to `{ $<fn>: '$field' }`. */
package/dist/agg.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as buildMongoFilter } from "./mongo-filter-BsocUQG3.mjs";
1
+ import { t as buildMongoFilter } from "./mongo-filter-DBYaF9aH.mjs";
2
2
  import { resolveAlias } from "@atscript/db/agg";
3
3
  //#region src/agg.ts
4
4
  /** Simple accumulators that map directly to `{ $<fn>: '$field' }`. */
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_mongo_filter = require("./mongo-filter-AihWWQXp.cjs");
2
+ const require_mongo_filter = require("./mongo-filter-1EpqdD-T.cjs");
3
3
  let _atscript_db = require("@atscript/db");
4
4
  let mongodb = require("mongodb");
5
5
  //#region src/lib/projection-dedupe.ts
@@ -72,6 +72,9 @@ function containsAggregationExpr(v) {
72
72
  * `$set` map.
73
73
  */
74
74
  var CollectionPatcher = class {
75
+ collection;
76
+ payload;
77
+ ops;
75
78
  constructor(collection, payload, ops) {
76
79
  this.collection = collection;
77
80
  this.payload = payload;
@@ -630,13 +633,13 @@ function isVectorSearchableImpl(host) {
630
633
  }
631
634
  /** Text search via $search aggregation stage. */
632
635
  async function searchImpl(host, text, query, indexName) {
633
- const plan = buildSearchStage(host, text, indexName);
636
+ const plan = buildSearchStage(host, text, indexName, query.controls);
634
637
  if (!plan) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
635
638
  return runSearchPipeline(host, plan.stage, query, "search", void 0, plan.classicText);
636
639
  }
637
640
  /** Text search with faceted count. */
638
641
  async function searchWithCountImpl(host, text, query, indexName) {
639
- const plan = buildSearchStage(host, text, indexName);
642
+ const plan = buildSearchStage(host, text, indexName, query.controls);
640
643
  if (!plan) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
641
644
  return runSearchWithCountPipeline(host, plan.stage, query, "searchWithCount", void 0, plan.classicText);
642
645
  }
@@ -672,7 +675,7 @@ function resolveThreshold(host, controls, indexName) {
672
675
  * MongoDB the stage is unsupported entirely — even though the table reports
673
676
  * `searchable: true`. The discriminator is the resolved index's `type`.
674
677
  */
675
- function buildSearchStage(host, text, indexName) {
678
+ function buildSearchStage(host, text, indexName, controls) {
676
679
  const index = host.getMongoSearchIndex(indexName);
677
680
  if (!index) return;
678
681
  if (index.type === "vector") throw new Error("Vector indexes cannot be used with text search. Use vectorSearch() instead.");
@@ -680,17 +683,58 @@ function buildSearchStage(host, text, indexName) {
680
683
  stage: { $match: { $text: { $search: text } } },
681
684
  classicText: true
682
685
  };
686
+ const searchIndex = index;
687
+ const fuzzy = resolveSearchFuzzy(searchIndex, controls);
688
+ const strategy = searchIndex.strategy ?? "compound";
689
+ const autocompletePaths = autocompleteFieldPaths(searchIndex);
690
+ const textClause = () => ({ text: {
691
+ query: text,
692
+ path: { wildcard: "*" },
693
+ ...fuzzy ? { fuzzy } : {}
694
+ } });
695
+ const autocompleteClause = (path) => ({ autocomplete: {
696
+ query: text,
697
+ path,
698
+ ...fuzzy ? { fuzzy } : {}
699
+ } });
700
+ let body;
701
+ if (strategy === "text" || autocompletePaths.length === 0) body = textClause();
702
+ else if (strategy === "autocomplete") {
703
+ const clauses = autocompletePaths.map(autocompleteClause);
704
+ body = clauses.length === 1 ? clauses[0] : { compound: {
705
+ should: clauses,
706
+ minimumShouldMatch: 1
707
+ } };
708
+ } else body = { compound: {
709
+ should: [textClause(), ...autocompletePaths.map(autocompleteClause)],
710
+ minimumShouldMatch: 1
711
+ } };
683
712
  return {
684
713
  stage: { $search: {
685
714
  index: index.key,
686
- text: {
687
- query: text,
688
- path: { wildcard: "*" }
689
- }
715
+ ...body
690
716
  } },
691
717
  classicText: false
692
718
  };
693
719
  }
720
+ /**
721
+ * Resolves query-time fuzzy (typo tolerance): the `$fuzzy` request control
722
+ * overrides the schema-declared `@db.mongo.search.*` fuzzy. Only an edit distance
723
+ * of 1 or 2 is emitted (Atlas rejects 0) — anything else means "no fuzzy".
724
+ */
725
+ function resolveSearchFuzzy(index, controls) {
726
+ const override = controls?.$fuzzy;
727
+ const maxEdits = override === void 0 ? index.fuzzy?.maxEdits : Number(override);
728
+ return maxEdits === 1 || maxEdits === 2 ? { maxEdits } : void 0;
729
+ }
730
+ /** Field paths in this index that carry an `autocomplete` mapping. */
731
+ function autocompleteFieldPaths(index) {
732
+ const fields = index.definition.mappings?.fields;
733
+ if (!fields) return [];
734
+ const paths = [];
735
+ for (const [path, mapping] of Object.entries(fields)) if ((Array.isArray(mapping) ? mapping : [mapping]).some((m) => m.type === "autocomplete")) paths.push(path);
736
+ return paths;
737
+ }
694
738
  /** Builds a $vectorSearch aggregation stage from a pre-computed vector. */
695
739
  function buildVectorSearchStage(host, vector, indexName, limit) {
696
740
  let index;
@@ -1289,10 +1333,26 @@ function fieldsMatch(left, right) {
1289
1333
  if (leftKeys.length !== rightKeys.length) return false;
1290
1334
  for (const key of leftKeys) {
1291
1335
  if (!(key in right)) return false;
1292
- if (left[key].type !== right[key].type || left[key].analyzer !== right[key].analyzer) return false;
1336
+ if (!fieldMappingEqual(left[key], right[key])) return false;
1293
1337
  }
1294
1338
  return true;
1295
1339
  }
1340
+ /** Order-independent structural compare of a field's Atlas type mapping(s). */
1341
+ function fieldMappingEqual(a, b) {
1342
+ const am = mappingsByType(a);
1343
+ const bm = mappingsByType(b);
1344
+ if (am.size !== bm.size) return false;
1345
+ for (const [type, av] of am) {
1346
+ const bv = bm.get(type);
1347
+ if (!bv || av.analyzer !== bv.analyzer || av.tokenization !== bv.tokenization || av.minGrams !== bv.minGrams || av.maxGrams !== bv.maxGrams || av.foldDiacritics !== bv.foldDiacritics) return false;
1348
+ }
1349
+ return true;
1350
+ }
1351
+ function mappingsByType(m) {
1352
+ const map = /* @__PURE__ */ new Map();
1353
+ for (const x of Array.isArray(m) ? m : [m]) map.set(x.type, x);
1354
+ return map;
1355
+ }
1296
1356
  function vectorFieldsMatch(left, right) {
1297
1357
  if (left.length !== (right || []).length) return false;
1298
1358
  const rightMap = /* @__PURE__ */ new Map();
@@ -1318,6 +1378,8 @@ const validateMongoIdPlugin = (ctx, def, value) => {
1318
1378
  //#endregion
1319
1379
  //#region src/lib/mongo-adapter.ts
1320
1380
  var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1381
+ db;
1382
+ client;
1321
1383
  _collection;
1322
1384
  /** MongoDB-specific indexes (search, vector) — separate from table.indexes. */
1323
1385
  _mongoIndexes = /* @__PURE__ */ new Map();
@@ -1508,13 +1570,14 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1508
1570
  const dynamicText = typeMeta.get("db.mongo.search.dynamic");
1509
1571
  if (dynamicText) this._setSearchIndex("dynamic_text", "_", {
1510
1572
  mappings: { dynamic: true },
1511
- analyzer: dynamicText.analyzer,
1512
- text: { fuzzy: { maxEdits: dynamicText.fuzzy || 0 } }
1513
- });
1573
+ analyzer: dynamicText.analyzer
1574
+ }, { fuzzy: normalizeSearchFuzzy(dynamicText.fuzzy) });
1514
1575
  for (const textSearch of typeMeta.get("db.mongo.search.static") || []) this._setSearchIndex("search_text", textSearch.indexName, {
1515
1576
  mappings: { fields: {} },
1516
- analyzer: textSearch.analyzer,
1517
- text: { fuzzy: { maxEdits: textSearch.fuzzy || 0 } }
1577
+ analyzer: textSearch.analyzer
1578
+ }, {
1579
+ fuzzy: normalizeSearchFuzzy(textSearch.fuzzy),
1580
+ strategy: normalizeSearchStrategy(textSearch.strategy)
1518
1581
  });
1519
1582
  }
1520
1583
  onFieldScanned(field, _type, metadata) {
@@ -1528,7 +1591,24 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1528
1591
  const startValue = metadata.get("db.default.increment");
1529
1592
  this._incrementFields.set(physicalName, typeof startValue === "number" ? startValue : void 0);
1530
1593
  }
1531
- for (const index of metadata.get("db.mongo.search.text") || []) this._addFieldToSearchIndex("search_text", index.indexName, field, index.analyzer);
1594
+ for (const index of metadata.get("db.mongo.search.text") || []) this._addFieldToSearchIndex("search_text", index.indexName, field, [index.analyzer ? {
1595
+ type: "string",
1596
+ analyzer: index.analyzer
1597
+ } : { type: "string" }]);
1598
+ for (const ac of metadata.get("db.mongo.search.autocomplete") || []) {
1599
+ const autocomplete = {
1600
+ type: "autocomplete",
1601
+ tokenization: ac.tokenization || "edgeGram",
1602
+ minGrams: ac.minGrams ?? 2,
1603
+ maxGrams: ac.maxGrams ?? 15,
1604
+ foldDiacritics: ac.foldDiacritics ?? true
1605
+ };
1606
+ const companion = ac.analyzer ? {
1607
+ type: "string",
1608
+ analyzer: ac.analyzer
1609
+ } : { type: "string" };
1610
+ this._addFieldToSearchIndex("search_text", ac.indexName, field, [autocomplete, companion]);
1611
+ }
1532
1612
  const vectorIndex = metadata.get("db.search.vector");
1533
1613
  if (vectorIndex) {
1534
1614
  const indexName = vectorIndex.indexName || field;
@@ -1989,32 +2069,68 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1989
2069
  }
1990
2070
  if (weight) index.weights[field] = weight;
1991
2071
  }
1992
- _setSearchIndex(type, name, definition) {
2072
+ _setSearchIndex(type, name, definition, meta) {
1993
2073
  const key = mongoIndexKey(type, name || "DEFAULT");
1994
- this._mongoIndexes.set(key, {
2074
+ const index = {
1995
2075
  key,
1996
2076
  name: name || "DEFAULT",
1997
2077
  type,
1998
- definition
1999
- });
2078
+ definition,
2079
+ fuzzy: meta?.fuzzy,
2080
+ strategy: meta?.strategy
2081
+ };
2082
+ this._mongoIndexes.set(key, index);
2083
+ return index;
2000
2084
  }
2001
- _addFieldToSearchIndex(type, _name, fieldName, analyzer) {
2085
+ /**
2086
+ * Adds (and merges, by mapping `type`) one or more Atlas field-type mappings to
2087
+ * a `search_text` index. Multiple annotations on the same field — e.g.
2088
+ * `@db.mongo.search.text` (string) plus `@db.mongo.search.autocomplete`
2089
+ * (autocomplete + string) — accumulate into a single multi-type mapping
2090
+ * instead of overwriting one another.
2091
+ */
2092
+ _addFieldToSearchIndex(type, _name, fieldName, mappings) {
2002
2093
  const name = _name || "DEFAULT";
2003
2094
  let index = this._mongoIndexes.get(mongoIndexKey(type, name));
2004
- if (!index && type === "search_text") {
2005
- this._setSearchIndex(type, name, {
2006
- mappings: { fields: {} },
2007
- text: { fuzzy: { maxEdits: 0 } }
2008
- });
2009
- index = this._mongoIndexes.get(mongoIndexKey(type, name));
2010
- }
2095
+ if (!index && type === "search_text") index = this._setSearchIndex(type, name, { mappings: { fields: {} } });
2011
2096
  if (index) {
2012
- index.definition.mappings.fields[fieldName] = { type: "string" };
2013
- if (analyzer) index.definition.mappings.fields[fieldName].analyzer = analyzer;
2097
+ const fields = index.definition.mappings.fields;
2098
+ fields[fieldName] = mergeFieldMappings(fields[fieldName], mappings);
2014
2099
  }
2015
2100
  }
2016
2101
  };
2017
2102
  /**
2103
+ * Normalizes a declared `fuzzy` arg (`0-2`) into the query-time metadata Atlas
2104
+ * accepts. Atlas only honors an edit distance of `1` or `2`; `0`/undefined means
2105
+ * "no fuzzy", returned as `undefined` so no `fuzzy` clause is ever emitted.
2106
+ */
2107
+ function normalizeSearchFuzzy(fuzzy) {
2108
+ return fuzzy === 1 || fuzzy === 2 ? { maxEdits: fuzzy } : void 0;
2109
+ }
2110
+ /** Narrows the declared `strategy` arg to a known value (undefined → query layer defaults to "compound"). */
2111
+ function normalizeSearchStrategy(strategy) {
2112
+ return strategy === "compound" || strategy === "autocomplete" || strategy === "text" ? strategy : void 0;
2113
+ }
2114
+ /**
2115
+ * Merges incoming Atlas field-type mappings into a field's existing mapping,
2116
+ * keyed by `type` (a later mapping of the same type replaces the earlier one).
2117
+ * Collapses to a single object when one type remains, else an array (Atlas's
2118
+ * multi-type field form).
2119
+ */
2120
+ function mergeFieldMappings(existing, incoming) {
2121
+ const byType = /* @__PURE__ */ new Map();
2122
+ const order = [];
2123
+ const add = (m) => {
2124
+ if (!byType.has(m.type)) order.push(m.type);
2125
+ byType.set(m.type, m);
2126
+ };
2127
+ if (Array.isArray(existing)) existing.forEach(add);
2128
+ else if (existing) add(existing);
2129
+ incoming.forEach(add);
2130
+ const merged = order.map((t) => byType.get(t));
2131
+ return merged.length === 1 ? merged[0] : merged;
2132
+ }
2133
+ /**
2018
2134
  * Builds a MongoDB update document from a data object that may contain
2019
2135
  * field ops (`{ $inc: N }`, `{ $dec: N }`, `{ $mul: N }`).
2020
2136
  * Regular fields go into `$set`, ops go into `$inc` / `$mul`. When
package/dist/index.d.cts CHANGED
@@ -165,16 +165,45 @@ interface TSearchIndex {
165
165
  name: string;
166
166
  type: "dynamic_text" | "search_text" | "vector";
167
167
  definition: TMongoSearchIndexDefinition;
168
+ /**
169
+ * Query-time fuzzy (typo tolerance) declared via `@db.mongo.search.dynamic` /
170
+ * `@db.mongo.search.static`. Carried as index metadata — NOT part of the Atlas
171
+ * index `definition` (Atlas applies fuzzy at query time on the `text`/
172
+ * `autocomplete` operator, not in the index schema). `buildSearchStage` reads
173
+ * this and attaches it to the emitted operator.
174
+ */
175
+ fuzzy?: {
176
+ maxEdits: number;
177
+ };
178
+ /**
179
+ * The match strategy declared via `@db.mongo.search.static`, locking this
180
+ * index's query shape. `buildSearchStage` reads it; undefined → `"compound"`.
181
+ * - `compound` — wildcard `text` + per-field `autocomplete` (exact ranks above prefix).
182
+ * - `autocomplete` — autocomplete fields only (pure typeahead, no word clause).
183
+ * - `text` — single `text` operator over all string-mapped fields.
184
+ */
185
+ strategy?: "compound" | "autocomplete" | "text";
168
186
  }
169
187
  type TMongoIndex = TPlainIndex | TSearchIndex;
170
188
  type TVectorSimilarity = "cosine" | "euclidean" | "dotProduct";
189
+ /**
190
+ * One Atlas Search field-type mapping. `type: "string"` is plain word matching;
191
+ * `type: "autocomplete"` enables prefix/typeahead (edgeGram) or substring (nGram).
192
+ * A field may carry several mappings at once (an array of these) — e.g. an
193
+ * autocomplete field double-mapped as `string` so exact-word hits still rank.
194
+ */
195
+ interface TSearchFieldMapping {
196
+ type: string;
197
+ analyzer?: string;
198
+ tokenization?: "edgeGram" | "rightEdgeGram" | "nGram";
199
+ minGrams?: number;
200
+ maxGrams?: number;
201
+ foldDiacritics?: boolean;
202
+ }
171
203
  interface TMongoSearchIndexDefinition {
172
204
  mappings?: {
173
205
  dynamic?: boolean;
174
- fields?: Record<string, {
175
- type: string;
176
- analyzer?: string;
177
- }>;
206
+ fields?: Record<string, TSearchFieldMapping | TSearchFieldMapping[]>;
178
207
  };
179
208
  fields?: Array<{
180
209
  path: string;
@@ -183,11 +212,6 @@ interface TMongoSearchIndexDefinition {
183
212
  numDimensions?: number;
184
213
  }>;
185
214
  analyzer?: string;
186
- text?: {
187
- fuzzy?: {
188
- maxEdits: number;
189
- };
190
- };
191
215
  }
192
216
  //#endregion
193
217
  //#region src/lib/mongo-adapter.d.ts
@@ -351,8 +375,20 @@ declare class MongoAdapter extends BaseDbAdapter {
351
375
  */
352
376
  private _getCollationOpts;
353
377
  protected _addMongoIndexField(type: TPlainIndex["type"], name: string, field: string, weight?: number): void;
354
- protected _setSearchIndex(type: TSearchIndex["type"], name: string | undefined, definition: TMongoSearchIndexDefinition): void;
355
- protected _addFieldToSearchIndex(type: TSearchIndex["type"], _name: string | undefined, fieldName: string, analyzer?: string): void;
378
+ protected _setSearchIndex(type: TSearchIndex["type"], name: string | undefined, definition: TMongoSearchIndexDefinition, meta?: {
379
+ fuzzy?: {
380
+ maxEdits: number;
381
+ };
382
+ strategy?: TSearchIndex["strategy"];
383
+ }): TSearchIndex;
384
+ /**
385
+ * Adds (and merges, by mapping `type`) one or more Atlas field-type mappings to
386
+ * a `search_text` index. Multiple annotations on the same field — e.g.
387
+ * `@db.mongo.search.text` (string) plus `@db.mongo.search.autocomplete`
388
+ * (autocomplete + string) — accumulate into a single multi-type mapping
389
+ * instead of overwriting one another.
390
+ */
391
+ protected _addFieldToSearchIndex(type: TSearchIndex["type"], _name: string | undefined, fieldName: string, mappings: TSearchFieldMapping[]): void;
356
392
  }
357
393
  //#endregion
358
394
  //#region src/lib/mongo-filter.d.ts
package/dist/index.d.mts CHANGED
@@ -165,16 +165,45 @@ interface TSearchIndex {
165
165
  name: string;
166
166
  type: "dynamic_text" | "search_text" | "vector";
167
167
  definition: TMongoSearchIndexDefinition;
168
+ /**
169
+ * Query-time fuzzy (typo tolerance) declared via `@db.mongo.search.dynamic` /
170
+ * `@db.mongo.search.static`. Carried as index metadata — NOT part of the Atlas
171
+ * index `definition` (Atlas applies fuzzy at query time on the `text`/
172
+ * `autocomplete` operator, not in the index schema). `buildSearchStage` reads
173
+ * this and attaches it to the emitted operator.
174
+ */
175
+ fuzzy?: {
176
+ maxEdits: number;
177
+ };
178
+ /**
179
+ * The match strategy declared via `@db.mongo.search.static`, locking this
180
+ * index's query shape. `buildSearchStage` reads it; undefined → `"compound"`.
181
+ * - `compound` — wildcard `text` + per-field `autocomplete` (exact ranks above prefix).
182
+ * - `autocomplete` — autocomplete fields only (pure typeahead, no word clause).
183
+ * - `text` — single `text` operator over all string-mapped fields.
184
+ */
185
+ strategy?: "compound" | "autocomplete" | "text";
168
186
  }
169
187
  type TMongoIndex = TPlainIndex | TSearchIndex;
170
188
  type TVectorSimilarity = "cosine" | "euclidean" | "dotProduct";
189
+ /**
190
+ * One Atlas Search field-type mapping. `type: "string"` is plain word matching;
191
+ * `type: "autocomplete"` enables prefix/typeahead (edgeGram) or substring (nGram).
192
+ * A field may carry several mappings at once (an array of these) — e.g. an
193
+ * autocomplete field double-mapped as `string` so exact-word hits still rank.
194
+ */
195
+ interface TSearchFieldMapping {
196
+ type: string;
197
+ analyzer?: string;
198
+ tokenization?: "edgeGram" | "rightEdgeGram" | "nGram";
199
+ minGrams?: number;
200
+ maxGrams?: number;
201
+ foldDiacritics?: boolean;
202
+ }
171
203
  interface TMongoSearchIndexDefinition {
172
204
  mappings?: {
173
205
  dynamic?: boolean;
174
- fields?: Record<string, {
175
- type: string;
176
- analyzer?: string;
177
- }>;
206
+ fields?: Record<string, TSearchFieldMapping | TSearchFieldMapping[]>;
178
207
  };
179
208
  fields?: Array<{
180
209
  path: string;
@@ -183,11 +212,6 @@ interface TMongoSearchIndexDefinition {
183
212
  numDimensions?: number;
184
213
  }>;
185
214
  analyzer?: string;
186
- text?: {
187
- fuzzy?: {
188
- maxEdits: number;
189
- };
190
- };
191
215
  }
192
216
  //#endregion
193
217
  //#region src/lib/mongo-adapter.d.ts
@@ -351,8 +375,20 @@ declare class MongoAdapter extends BaseDbAdapter {
351
375
  */
352
376
  private _getCollationOpts;
353
377
  protected _addMongoIndexField(type: TPlainIndex["type"], name: string, field: string, weight?: number): void;
354
- protected _setSearchIndex(type: TSearchIndex["type"], name: string | undefined, definition: TMongoSearchIndexDefinition): void;
355
- protected _addFieldToSearchIndex(type: TSearchIndex["type"], _name: string | undefined, fieldName: string, analyzer?: string): void;
378
+ protected _setSearchIndex(type: TSearchIndex["type"], name: string | undefined, definition: TMongoSearchIndexDefinition, meta?: {
379
+ fuzzy?: {
380
+ maxEdits: number;
381
+ };
382
+ strategy?: TSearchIndex["strategy"];
383
+ }): TSearchIndex;
384
+ /**
385
+ * Adds (and merges, by mapping `type`) one or more Atlas field-type mappings to
386
+ * a `search_text` index. Multiple annotations on the same field — e.g.
387
+ * `@db.mongo.search.text` (string) plus `@db.mongo.search.autocomplete`
388
+ * (autocomplete + string) — accumulate into a single multi-type mapping
389
+ * instead of overwriting one another.
390
+ */
391
+ protected _addFieldToSearchIndex(type: TSearchIndex["type"], _name: string | undefined, fieldName: string, mappings: TSearchFieldMapping[]): void;
356
392
  }
357
393
  //#endregion
358
394
  //#region src/lib/mongo-filter.d.ts
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as buildMongoFilter } from "./mongo-filter-BsocUQG3.mjs";
1
+ import { t as buildMongoFilter } from "./mongo-filter-DBYaF9aH.mjs";
2
2
  import { AtscriptDbView, BaseDbAdapter, DbError, DbSpace, computeInsights, getDbFieldOp, getKeyProps, resolveDesignType } from "@atscript/db";
3
3
  import { MongoClient, MongoServerError, ObjectId } from "mongodb";
4
4
  //#region src/lib/projection-dedupe.ts
@@ -71,6 +71,9 @@ function containsAggregationExpr(v) {
71
71
  * `$set` map.
72
72
  */
73
73
  var CollectionPatcher = class {
74
+ collection;
75
+ payload;
76
+ ops;
74
77
  constructor(collection, payload, ops) {
75
78
  this.collection = collection;
76
79
  this.payload = payload;
@@ -629,13 +632,13 @@ function isVectorSearchableImpl(host) {
629
632
  }
630
633
  /** Text search via $search aggregation stage. */
631
634
  async function searchImpl(host, text, query, indexName) {
632
- const plan = buildSearchStage(host, text, indexName);
635
+ const plan = buildSearchStage(host, text, indexName, query.controls);
633
636
  if (!plan) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
634
637
  return runSearchPipeline(host, plan.stage, query, "search", void 0, plan.classicText);
635
638
  }
636
639
  /** Text search with faceted count. */
637
640
  async function searchWithCountImpl(host, text, query, indexName) {
638
- const plan = buildSearchStage(host, text, indexName);
641
+ const plan = buildSearchStage(host, text, indexName, query.controls);
639
642
  if (!plan) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
640
643
  return runSearchWithCountPipeline(host, plan.stage, query, "searchWithCount", void 0, plan.classicText);
641
644
  }
@@ -671,7 +674,7 @@ function resolveThreshold(host, controls, indexName) {
671
674
  * MongoDB the stage is unsupported entirely — even though the table reports
672
675
  * `searchable: true`. The discriminator is the resolved index's `type`.
673
676
  */
674
- function buildSearchStage(host, text, indexName) {
677
+ function buildSearchStage(host, text, indexName, controls) {
675
678
  const index = host.getMongoSearchIndex(indexName);
676
679
  if (!index) return;
677
680
  if (index.type === "vector") throw new Error("Vector indexes cannot be used with text search. Use vectorSearch() instead.");
@@ -679,17 +682,58 @@ function buildSearchStage(host, text, indexName) {
679
682
  stage: { $match: { $text: { $search: text } } },
680
683
  classicText: true
681
684
  };
685
+ const searchIndex = index;
686
+ const fuzzy = resolveSearchFuzzy(searchIndex, controls);
687
+ const strategy = searchIndex.strategy ?? "compound";
688
+ const autocompletePaths = autocompleteFieldPaths(searchIndex);
689
+ const textClause = () => ({ text: {
690
+ query: text,
691
+ path: { wildcard: "*" },
692
+ ...fuzzy ? { fuzzy } : {}
693
+ } });
694
+ const autocompleteClause = (path) => ({ autocomplete: {
695
+ query: text,
696
+ path,
697
+ ...fuzzy ? { fuzzy } : {}
698
+ } });
699
+ let body;
700
+ if (strategy === "text" || autocompletePaths.length === 0) body = textClause();
701
+ else if (strategy === "autocomplete") {
702
+ const clauses = autocompletePaths.map(autocompleteClause);
703
+ body = clauses.length === 1 ? clauses[0] : { compound: {
704
+ should: clauses,
705
+ minimumShouldMatch: 1
706
+ } };
707
+ } else body = { compound: {
708
+ should: [textClause(), ...autocompletePaths.map(autocompleteClause)],
709
+ minimumShouldMatch: 1
710
+ } };
682
711
  return {
683
712
  stage: { $search: {
684
713
  index: index.key,
685
- text: {
686
- query: text,
687
- path: { wildcard: "*" }
688
- }
714
+ ...body
689
715
  } },
690
716
  classicText: false
691
717
  };
692
718
  }
719
+ /**
720
+ * Resolves query-time fuzzy (typo tolerance): the `$fuzzy` request control
721
+ * overrides the schema-declared `@db.mongo.search.*` fuzzy. Only an edit distance
722
+ * of 1 or 2 is emitted (Atlas rejects 0) — anything else means "no fuzzy".
723
+ */
724
+ function resolveSearchFuzzy(index, controls) {
725
+ const override = controls?.$fuzzy;
726
+ const maxEdits = override === void 0 ? index.fuzzy?.maxEdits : Number(override);
727
+ return maxEdits === 1 || maxEdits === 2 ? { maxEdits } : void 0;
728
+ }
729
+ /** Field paths in this index that carry an `autocomplete` mapping. */
730
+ function autocompleteFieldPaths(index) {
731
+ const fields = index.definition.mappings?.fields;
732
+ if (!fields) return [];
733
+ const paths = [];
734
+ for (const [path, mapping] of Object.entries(fields)) if ((Array.isArray(mapping) ? mapping : [mapping]).some((m) => m.type === "autocomplete")) paths.push(path);
735
+ return paths;
736
+ }
693
737
  /** Builds a $vectorSearch aggregation stage from a pre-computed vector. */
694
738
  function buildVectorSearchStage(host, vector, indexName, limit) {
695
739
  let index;
@@ -1288,10 +1332,26 @@ function fieldsMatch(left, right) {
1288
1332
  if (leftKeys.length !== rightKeys.length) return false;
1289
1333
  for (const key of leftKeys) {
1290
1334
  if (!(key in right)) return false;
1291
- if (left[key].type !== right[key].type || left[key].analyzer !== right[key].analyzer) return false;
1335
+ if (!fieldMappingEqual(left[key], right[key])) return false;
1292
1336
  }
1293
1337
  return true;
1294
1338
  }
1339
+ /** Order-independent structural compare of a field's Atlas type mapping(s). */
1340
+ function fieldMappingEqual(a, b) {
1341
+ const am = mappingsByType(a);
1342
+ const bm = mappingsByType(b);
1343
+ if (am.size !== bm.size) return false;
1344
+ for (const [type, av] of am) {
1345
+ const bv = bm.get(type);
1346
+ if (!bv || av.analyzer !== bv.analyzer || av.tokenization !== bv.tokenization || av.minGrams !== bv.minGrams || av.maxGrams !== bv.maxGrams || av.foldDiacritics !== bv.foldDiacritics) return false;
1347
+ }
1348
+ return true;
1349
+ }
1350
+ function mappingsByType(m) {
1351
+ const map = /* @__PURE__ */ new Map();
1352
+ for (const x of Array.isArray(m) ? m : [m]) map.set(x.type, x);
1353
+ return map;
1354
+ }
1295
1355
  function vectorFieldsMatch(left, right) {
1296
1356
  if (left.length !== (right || []).length) return false;
1297
1357
  const rightMap = /* @__PURE__ */ new Map();
@@ -1317,6 +1377,8 @@ const validateMongoIdPlugin = (ctx, def, value) => {
1317
1377
  //#endregion
1318
1378
  //#region src/lib/mongo-adapter.ts
1319
1379
  var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1380
+ db;
1381
+ client;
1320
1382
  _collection;
1321
1383
  /** MongoDB-specific indexes (search, vector) — separate from table.indexes. */
1322
1384
  _mongoIndexes = /* @__PURE__ */ new Map();
@@ -1507,13 +1569,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1507
1569
  const dynamicText = typeMeta.get("db.mongo.search.dynamic");
1508
1570
  if (dynamicText) this._setSearchIndex("dynamic_text", "_", {
1509
1571
  mappings: { dynamic: true },
1510
- analyzer: dynamicText.analyzer,
1511
- text: { fuzzy: { maxEdits: dynamicText.fuzzy || 0 } }
1512
- });
1572
+ analyzer: dynamicText.analyzer
1573
+ }, { fuzzy: normalizeSearchFuzzy(dynamicText.fuzzy) });
1513
1574
  for (const textSearch of typeMeta.get("db.mongo.search.static") || []) this._setSearchIndex("search_text", textSearch.indexName, {
1514
1575
  mappings: { fields: {} },
1515
- analyzer: textSearch.analyzer,
1516
- text: { fuzzy: { maxEdits: textSearch.fuzzy || 0 } }
1576
+ analyzer: textSearch.analyzer
1577
+ }, {
1578
+ fuzzy: normalizeSearchFuzzy(textSearch.fuzzy),
1579
+ strategy: normalizeSearchStrategy(textSearch.strategy)
1517
1580
  });
1518
1581
  }
1519
1582
  onFieldScanned(field, _type, metadata) {
@@ -1527,7 +1590,24 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1527
1590
  const startValue = metadata.get("db.default.increment");
1528
1591
  this._incrementFields.set(physicalName, typeof startValue === "number" ? startValue : void 0);
1529
1592
  }
1530
- for (const index of metadata.get("db.mongo.search.text") || []) this._addFieldToSearchIndex("search_text", index.indexName, field, index.analyzer);
1593
+ for (const index of metadata.get("db.mongo.search.text") || []) this._addFieldToSearchIndex("search_text", index.indexName, field, [index.analyzer ? {
1594
+ type: "string",
1595
+ analyzer: index.analyzer
1596
+ } : { type: "string" }]);
1597
+ for (const ac of metadata.get("db.mongo.search.autocomplete") || []) {
1598
+ const autocomplete = {
1599
+ type: "autocomplete",
1600
+ tokenization: ac.tokenization || "edgeGram",
1601
+ minGrams: ac.minGrams ?? 2,
1602
+ maxGrams: ac.maxGrams ?? 15,
1603
+ foldDiacritics: ac.foldDiacritics ?? true
1604
+ };
1605
+ const companion = ac.analyzer ? {
1606
+ type: "string",
1607
+ analyzer: ac.analyzer
1608
+ } : { type: "string" };
1609
+ this._addFieldToSearchIndex("search_text", ac.indexName, field, [autocomplete, companion]);
1610
+ }
1531
1611
  const vectorIndex = metadata.get("db.search.vector");
1532
1612
  if (vectorIndex) {
1533
1613
  const indexName = vectorIndex.indexName || field;
@@ -1988,32 +2068,68 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1988
2068
  }
1989
2069
  if (weight) index.weights[field] = weight;
1990
2070
  }
1991
- _setSearchIndex(type, name, definition) {
2071
+ _setSearchIndex(type, name, definition, meta) {
1992
2072
  const key = mongoIndexKey(type, name || "DEFAULT");
1993
- this._mongoIndexes.set(key, {
2073
+ const index = {
1994
2074
  key,
1995
2075
  name: name || "DEFAULT",
1996
2076
  type,
1997
- definition
1998
- });
2077
+ definition,
2078
+ fuzzy: meta?.fuzzy,
2079
+ strategy: meta?.strategy
2080
+ };
2081
+ this._mongoIndexes.set(key, index);
2082
+ return index;
1999
2083
  }
2000
- _addFieldToSearchIndex(type, _name, fieldName, analyzer) {
2084
+ /**
2085
+ * Adds (and merges, by mapping `type`) one or more Atlas field-type mappings to
2086
+ * a `search_text` index. Multiple annotations on the same field — e.g.
2087
+ * `@db.mongo.search.text` (string) plus `@db.mongo.search.autocomplete`
2088
+ * (autocomplete + string) — accumulate into a single multi-type mapping
2089
+ * instead of overwriting one another.
2090
+ */
2091
+ _addFieldToSearchIndex(type, _name, fieldName, mappings) {
2001
2092
  const name = _name || "DEFAULT";
2002
2093
  let index = this._mongoIndexes.get(mongoIndexKey(type, name));
2003
- if (!index && type === "search_text") {
2004
- this._setSearchIndex(type, name, {
2005
- mappings: { fields: {} },
2006
- text: { fuzzy: { maxEdits: 0 } }
2007
- });
2008
- index = this._mongoIndexes.get(mongoIndexKey(type, name));
2009
- }
2094
+ if (!index && type === "search_text") index = this._setSearchIndex(type, name, { mappings: { fields: {} } });
2010
2095
  if (index) {
2011
- index.definition.mappings.fields[fieldName] = { type: "string" };
2012
- if (analyzer) index.definition.mappings.fields[fieldName].analyzer = analyzer;
2096
+ const fields = index.definition.mappings.fields;
2097
+ fields[fieldName] = mergeFieldMappings(fields[fieldName], mappings);
2013
2098
  }
2014
2099
  }
2015
2100
  };
2016
2101
  /**
2102
+ * Normalizes a declared `fuzzy` arg (`0-2`) into the query-time metadata Atlas
2103
+ * accepts. Atlas only honors an edit distance of `1` or `2`; `0`/undefined means
2104
+ * "no fuzzy", returned as `undefined` so no `fuzzy` clause is ever emitted.
2105
+ */
2106
+ function normalizeSearchFuzzy(fuzzy) {
2107
+ return fuzzy === 1 || fuzzy === 2 ? { maxEdits: fuzzy } : void 0;
2108
+ }
2109
+ /** Narrows the declared `strategy` arg to a known value (undefined → query layer defaults to "compound"). */
2110
+ function normalizeSearchStrategy(strategy) {
2111
+ return strategy === "compound" || strategy === "autocomplete" || strategy === "text" ? strategy : void 0;
2112
+ }
2113
+ /**
2114
+ * Merges incoming Atlas field-type mappings into a field's existing mapping,
2115
+ * keyed by `type` (a later mapping of the same type replaces the earlier one).
2116
+ * Collapses to a single object when one type remains, else an array (Atlas's
2117
+ * multi-type field form).
2118
+ */
2119
+ function mergeFieldMappings(existing, incoming) {
2120
+ const byType = /* @__PURE__ */ new Map();
2121
+ const order = [];
2122
+ const add = (m) => {
2123
+ if (!byType.has(m.type)) order.push(m.type);
2124
+ byType.set(m.type, m);
2125
+ };
2126
+ if (Array.isArray(existing)) existing.forEach(add);
2127
+ else if (existing) add(existing);
2128
+ incoming.forEach(add);
2129
+ const merged = order.map((t) => byType.get(t));
2130
+ return merged.length === 1 ? merged[0] : merged;
2131
+ }
2132
+ /**
2017
2133
  * Builds a MongoDB update document from a data object that may contain
2018
2134
  * field ops (`{ $inc: N }`, `{ $dec: N }`, `{ $mul: N }`).
2019
2135
  * Regular fields go into `$set`, ops go into `$inc` / `$mul`. When
package/dist/plugin.cjs CHANGED
@@ -20,6 +20,18 @@ const analyzers = [
20
20
  "lucene.russian",
21
21
  "lucene.arabic"
22
22
  ];
23
+ const tokenizations = [
24
+ "edgeGram",
25
+ "rightEdgeGram",
26
+ "nGram"
27
+ ];
28
+ const searchStrategies = [
29
+ "compound",
30
+ "autocomplete",
31
+ "text"
32
+ ];
33
+ const strategyDescription = "How `search()` matches a term against this index. Locks the query shape into the index — there is no query-time mode switching.\n\n- `compound` (default) → rank exact-word hits above prefix hits: a wildcard `text` clause **plus** one `autocomplete` clause per autocomplete field. Degrades to plain `text` when the index has no autocomplete field.\n- `autocomplete` → **prefix/typeahead only** — query just the autocomplete fields, no word-match ranking clause.\n- `text` → **word matching only** — a single `text` operator over all string-mapped fields (autocomplete fields are matched via their companion `string` mapping).\n\nTo use the same data with a different strategy, declare a second index and select it per request with `$index`.";
34
+ const fuzzyDescription = "Maximum typo tolerance, applied **at query time** to the search operator.\n\n- `0` (default) → no fuzzy matching (exact tokens).\n- `1` → allows small typos (e.g., `\"mongo\"` ≈ `\"mango\"`).\n- `2` → more typo tolerance (e.g., `\"mongodb\"` ≈ `\"mangodb\"`).\n\nAtlas only accepts an edit distance of `1` or `2`; `0` simply disables fuzzy. Can be overridden per request via the `$fuzzy` query control.";
23
35
  /**
24
36
  * MongoDB-specific annotations.
25
37
  *
@@ -107,7 +119,7 @@ const annotations = {
107
119
  optional: true,
108
120
  name: "fuzzy",
109
121
  type: "number",
110
- description: "Maximum typo tolerance (`0-2`). Defaults to `0` (no fuzzy search).\n\n- `0` → Exact match required.\n- `1` → Allows small typos (e.g., `\"mongo\"` ≈ `\"mango\"`).\n- `2` → More typo tolerance (e.g., `\"mongodb\"` ≈ `\"mangodb\"`)."
122
+ description: fuzzyDescription
111
123
  }]
112
124
  }),
113
125
  static: new _atscript_core.AnnotationSpec({
@@ -126,13 +138,20 @@ const annotations = {
126
138
  optional: true,
127
139
  name: "fuzzy",
128
140
  type: "number",
129
- description: "Maximum typo tolerance (`0-2`). **Defaults to `0` (no fuzzy matching).**\n\n- `0` → No typos allowed (exact match required).\n- `1` → Allows small typos (e.g., \"mongo\" ≈ \"mango\").\n- `2` → More typo tolerance (e.g., \"mongodb\" ≈ \"mangodb\")."
141
+ description: fuzzyDescription
130
142
  },
131
143
  {
132
144
  optional: true,
133
145
  name: "indexName",
134
146
  type: "string",
135
- description: "The name of the search index. Fields must reference this name using `@db.mongo.search.text`. If not set, defaults to `\"DEFAULT\"`."
147
+ description: "The name of the search index. Fields must reference this name using `@db.mongo.search.text` or `@db.mongo.search.autocomplete`. If not set, defaults to `\"DEFAULT\"`."
148
+ },
149
+ {
150
+ optional: true,
151
+ name: "strategy",
152
+ type: "string",
153
+ description: strategyDescription,
154
+ values: searchStrategies
136
155
  }
137
156
  ]
138
157
  }),
@@ -152,6 +171,51 @@ const annotations = {
152
171
  type: "string",
153
172
  description: "The **name of the search index** defined in `@db.mongo.search.static`. This links the field to the correct index. If not set, defaults to `\"DEFAULT\"`."
154
173
  }]
174
+ }),
175
+ autocomplete: new _atscript_core.AnnotationSpec({
176
+ description: "Marks a field for **prefix / typeahead (as-you-type)** matching in a MongoDB Atlas Search Index.\n\n- Indexes the field as the Atlas **`autocomplete`** type, **and** double-maps it as `string` so exact-word hits still rank.\n- Lets `search()` match partial words: with the default `edgeGram` tokenization, `\"art\"` matches `\"Artem\"` **as you type** (no whole word required).\n- Use `nGram` tokenization for true mid-word (infix/substring) matching at higher index cost.\n- Like `@db.mongo.search.text`, the field joins the index named by `indexName` (or the default index).\n\n**Example:**\n```atscript\n@db.mongo.search.autocomplete \"users\"\nusername: string\n```\n",
177
+ nodeType: ["prop"],
178
+ multiple: true,
179
+ argument: [
180
+ {
181
+ optional: true,
182
+ name: "indexName",
183
+ type: "string",
184
+ description: "The **name of the search index** (defined by `@db.mongo.search.static`) this field joins. If not set, defaults to `\"DEFAULT\"`."
185
+ },
186
+ {
187
+ optional: true,
188
+ name: "tokenization",
189
+ type: "string",
190
+ description: "How the field is tokenized for partial matching:\n\n- `\"edgeGram\"` (default) → **prefix** matching from the start of each word (`\"art\"` → `\"Artem\"`).\n- `\"nGram\"` → **substring/infix** matching anywhere inside a word (`\"tem\"` → `\"Artem\"`); larger index, slower builds.\n- `\"rightEdgeGram\"` → **suffix** matching from the end of each word.",
191
+ values: tokenizations
192
+ },
193
+ {
194
+ optional: true,
195
+ name: "minGrams",
196
+ type: "number",
197
+ description: "Minimum number of characters per indexed sequence. Defaults to `2`."
198
+ },
199
+ {
200
+ optional: true,
201
+ name: "maxGrams",
202
+ type: "number",
203
+ description: "Maximum number of characters per indexed sequence. Defaults to `15`."
204
+ },
205
+ {
206
+ optional: true,
207
+ name: "foldDiacritics",
208
+ type: "boolean",
209
+ description: "Whether to fold (ignore) diacritics so `\"café\"` matches `\"cafe\"`. Defaults to `true`."
210
+ },
211
+ {
212
+ optional: true,
213
+ name: "analyzer",
214
+ type: "string",
215
+ description: "The text analyzer for the companion `string` mapping. Defaults to `\"lucene.standard\"`.",
216
+ values: analyzers
217
+ }
218
+ ]
155
219
  })
156
220
  }
157
221
  };
package/dist/plugin.mjs CHANGED
@@ -16,6 +16,18 @@ const analyzers = [
16
16
  "lucene.russian",
17
17
  "lucene.arabic"
18
18
  ];
19
+ const tokenizations = [
20
+ "edgeGram",
21
+ "rightEdgeGram",
22
+ "nGram"
23
+ ];
24
+ const searchStrategies = [
25
+ "compound",
26
+ "autocomplete",
27
+ "text"
28
+ ];
29
+ const strategyDescription = "How `search()` matches a term against this index. Locks the query shape into the index — there is no query-time mode switching.\n\n- `compound` (default) → rank exact-word hits above prefix hits: a wildcard `text` clause **plus** one `autocomplete` clause per autocomplete field. Degrades to plain `text` when the index has no autocomplete field.\n- `autocomplete` → **prefix/typeahead only** — query just the autocomplete fields, no word-match ranking clause.\n- `text` → **word matching only** — a single `text` operator over all string-mapped fields (autocomplete fields are matched via their companion `string` mapping).\n\nTo use the same data with a different strategy, declare a second index and select it per request with `$index`.";
30
+ const fuzzyDescription = "Maximum typo tolerance, applied **at query time** to the search operator.\n\n- `0` (default) → no fuzzy matching (exact tokens).\n- `1` → allows small typos (e.g., `\"mongo\"` ≈ `\"mango\"`).\n- `2` → more typo tolerance (e.g., `\"mongodb\"` ≈ `\"mangodb\"`).\n\nAtlas only accepts an edit distance of `1` or `2`; `0` simply disables fuzzy. Can be overridden per request via the `$fuzzy` query control.";
19
31
  /**
20
32
  * MongoDB-specific annotations.
21
33
  *
@@ -103,7 +115,7 @@ const annotations = {
103
115
  optional: true,
104
116
  name: "fuzzy",
105
117
  type: "number",
106
- description: "Maximum typo tolerance (`0-2`). Defaults to `0` (no fuzzy search).\n\n- `0` → Exact match required.\n- `1` → Allows small typos (e.g., `\"mongo\"` ≈ `\"mango\"`).\n- `2` → More typo tolerance (e.g., `\"mongodb\"` ≈ `\"mangodb\"`)."
118
+ description: fuzzyDescription
107
119
  }]
108
120
  }),
109
121
  static: new AnnotationSpec({
@@ -122,13 +134,20 @@ const annotations = {
122
134
  optional: true,
123
135
  name: "fuzzy",
124
136
  type: "number",
125
- description: "Maximum typo tolerance (`0-2`). **Defaults to `0` (no fuzzy matching).**\n\n- `0` → No typos allowed (exact match required).\n- `1` → Allows small typos (e.g., \"mongo\" ≈ \"mango\").\n- `2` → More typo tolerance (e.g., \"mongodb\" ≈ \"mangodb\")."
137
+ description: fuzzyDescription
126
138
  },
127
139
  {
128
140
  optional: true,
129
141
  name: "indexName",
130
142
  type: "string",
131
- description: "The name of the search index. Fields must reference this name using `@db.mongo.search.text`. If not set, defaults to `\"DEFAULT\"`."
143
+ description: "The name of the search index. Fields must reference this name using `@db.mongo.search.text` or `@db.mongo.search.autocomplete`. If not set, defaults to `\"DEFAULT\"`."
144
+ },
145
+ {
146
+ optional: true,
147
+ name: "strategy",
148
+ type: "string",
149
+ description: strategyDescription,
150
+ values: searchStrategies
132
151
  }
133
152
  ]
134
153
  }),
@@ -148,6 +167,51 @@ const annotations = {
148
167
  type: "string",
149
168
  description: "The **name of the search index** defined in `@db.mongo.search.static`. This links the field to the correct index. If not set, defaults to `\"DEFAULT\"`."
150
169
  }]
170
+ }),
171
+ autocomplete: new AnnotationSpec({
172
+ description: "Marks a field for **prefix / typeahead (as-you-type)** matching in a MongoDB Atlas Search Index.\n\n- Indexes the field as the Atlas **`autocomplete`** type, **and** double-maps it as `string` so exact-word hits still rank.\n- Lets `search()` match partial words: with the default `edgeGram` tokenization, `\"art\"` matches `\"Artem\"` **as you type** (no whole word required).\n- Use `nGram` tokenization for true mid-word (infix/substring) matching at higher index cost.\n- Like `@db.mongo.search.text`, the field joins the index named by `indexName` (or the default index).\n\n**Example:**\n```atscript\n@db.mongo.search.autocomplete \"users\"\nusername: string\n```\n",
173
+ nodeType: ["prop"],
174
+ multiple: true,
175
+ argument: [
176
+ {
177
+ optional: true,
178
+ name: "indexName",
179
+ type: "string",
180
+ description: "The **name of the search index** (defined by `@db.mongo.search.static`) this field joins. If not set, defaults to `\"DEFAULT\"`."
181
+ },
182
+ {
183
+ optional: true,
184
+ name: "tokenization",
185
+ type: "string",
186
+ description: "How the field is tokenized for partial matching:\n\n- `\"edgeGram\"` (default) → **prefix** matching from the start of each word (`\"art\"` → `\"Artem\"`).\n- `\"nGram\"` → **substring/infix** matching anywhere inside a word (`\"tem\"` → `\"Artem\"`); larger index, slower builds.\n- `\"rightEdgeGram\"` → **suffix** matching from the end of each word.",
187
+ values: tokenizations
188
+ },
189
+ {
190
+ optional: true,
191
+ name: "minGrams",
192
+ type: "number",
193
+ description: "Minimum number of characters per indexed sequence. Defaults to `2`."
194
+ },
195
+ {
196
+ optional: true,
197
+ name: "maxGrams",
198
+ type: "number",
199
+ description: "Maximum number of characters per indexed sequence. Defaults to `15`."
200
+ },
201
+ {
202
+ optional: true,
203
+ name: "foldDiacritics",
204
+ type: "boolean",
205
+ description: "Whether to fold (ignore) diacritics so `\"café\"` matches `\"cafe\"`. Defaults to `true`."
206
+ },
207
+ {
208
+ optional: true,
209
+ name: "analyzer",
210
+ type: "string",
211
+ description: "The text analyzer for the companion `string` mapping. Defaults to `\"lucene.standard\"`.",
212
+ values: analyzers
213
+ }
214
+ ]
151
215
  })
152
216
  }
153
217
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-mongo",
3
- "version": "0.1.101",
3
+ "version": "0.1.102",
4
4
  "description": "Mongodb plugin for atscript.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -56,7 +56,7 @@
56
56
  "@atscript/core": "^0.1.74",
57
57
  "@atscript/typescript": "^0.1.74",
58
58
  "mongodb": "^6.17.0",
59
- "@atscript/db": "^0.1.101"
59
+ "@atscript/db": "^0.1.102"
60
60
  },
61
61
  "scripts": {
62
62
  "postinstall": "asc -f dts",