@atscript/db-mongo 0.1.107 → 0.1.109

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
@@ -3,6 +3,13 @@ const require_mongo_filter = require("./mongo-filter-z_hLPMyv.cjs");
3
3
  let _atscript_db = require("@atscript/db");
4
4
  let mongodb = require("mongodb");
5
5
  //#region src/lib/path-utils.ts
6
+ function joinPath(prefix, segment) {
7
+ return prefix ? `${prefix}.${segment}` : segment;
8
+ }
9
+ function isArrayPath(flatMap, path) {
10
+ const type = flatMap.get(path);
11
+ return type !== void 0 && (0, _atscript_db.resolveDesignType)(type) === "array";
12
+ }
6
13
  function hasAncestorIn(path, set) {
7
14
  let dot = path.indexOf(".");
8
15
  while (dot !== -1) {
@@ -157,7 +164,7 @@ var CollectionPatcher = class {
157
164
  * @private
158
165
  */
159
166
  flattenPayload(payload, prefix = "") {
160
- const evalKey = (k) => prefix ? `${prefix}.${k}` : k;
167
+ const evalKey = (k) => joinPath(prefix, k);
161
168
  for (const [_key, value] of Object.entries(payload)) {
162
169
  const key = evalKey(_key);
163
170
  const flatType = this.collection.flatMap.get(key);
@@ -770,27 +777,44 @@ function buildSearchStage(host, text, indexName, controls) {
770
777
  const searchIndex = index;
771
778
  const fuzzy = resolveSearchFuzzy(searchIndex, controls);
772
779
  const strategy = searchIndex.strategy ?? "compound";
773
- const autocompletePaths = autocompleteFieldPaths(searchIndex);
774
- const textClause = () => ({ text: {
780
+ const paths = collectSearchPathsCached(searchIndex);
781
+ const fuzzyOpt = fuzzy ? { fuzzy } : {};
782
+ const textOp = (path) => ({ text: {
775
783
  query: text,
776
- path: { wildcard: "*" },
777
- ...fuzzy ? { fuzzy } : {}
784
+ path,
785
+ ...fuzzyOpt
778
786
  } });
779
- const autocompleteClause = (path) => ({ autocomplete: {
787
+ const autocompleteOp = (path) => ({ autocomplete: {
780
788
  query: text,
781
789
  path,
782
- ...fuzzy ? { fuzzy } : {}
790
+ ...fuzzyOpt
783
791
  } });
784
- let body;
785
- if (strategy === "text" || autocompletePaths.length === 0) body = textClause();
786
- else if (strategy === "autocomplete") {
787
- const clauses = autocompletePaths.map(autocompleteClause);
788
- body = clauses.length === 1 ? clauses[0] : { compound: {
789
- should: clauses,
790
- minimumShouldMatch: 1
792
+ const textClause = () => textOp({ wildcard: "*" });
793
+ const wrapEmbedded = (chain, operator) => {
794
+ let wrapped = operator;
795
+ for (let i = chain.length - 1; i >= 0; i--) wrapped = { embeddedDocument: {
796
+ path: chain[i],
797
+ operator: wrapped
791
798
  } };
792
- } else body = { compound: {
793
- should: [textClause(), ...autocompletePaths.map(autocompleteClause)],
799
+ return wrapped;
800
+ };
801
+ const embeddedTextClause = (chain, leaves) => wrapEmbedded(chain, textOp(leaves.length === 1 ? leaves[0] : leaves));
802
+ const embeddedAutocompleteClause = (chain, leaf) => wrapEmbedded(chain, autocompleteOp(leaf));
803
+ const wantWord = strategy === "text" || strategy === "compound";
804
+ const wantAutocomplete = strategy === "autocomplete" || strategy === "compound";
805
+ const clauses = [];
806
+ if (wantWord) {
807
+ clauses.push(textClause());
808
+ for (const group of paths.arrayGroups) if (group.textLeaves.length > 0) clauses.push(embeddedTextClause(group.chain, group.textLeaves));
809
+ }
810
+ if (wantAutocomplete) {
811
+ for (const path of paths.autocompleteOutside) clauses.push(autocompleteOp(path));
812
+ for (const group of paths.arrayGroups) for (const leaf of group.autocompleteLeaves) clauses.push(embeddedAutocompleteClause(group.chain, leaf));
813
+ }
814
+ let body;
815
+ if (clauses.length <= 1) body = clauses[0] ?? textClause();
816
+ else body = { compound: {
817
+ should: clauses,
794
818
  minimumShouldMatch: 1
795
819
  } };
796
820
  return {
@@ -811,13 +835,65 @@ function resolveSearchFuzzy(index, controls) {
811
835
  const maxEdits = override === void 0 ? index.fuzzy?.maxEdits : Number(override);
812
836
  return maxEdits === 1 || maxEdits === 2 ? { maxEdits } : void 0;
813
837
  }
814
- /** Field paths in this index that carry an `autocomplete` mapping. */
815
- function autocompleteFieldPaths(index) {
816
- const fields = index.definition.mappings?.fields;
817
- if (!fields) return [];
818
- const paths = [];
819
- for (const [path, mapping] of Object.entries(fields)) if ((Array.isArray(mapping) ? mapping : [mapping]).some((m) => m.type === "autocomplete")) paths.push(path);
820
- return paths;
838
+ /**
839
+ * An index's mapping tree is immutable once schema sync builds it, and the index
840
+ * object is cached for the adapter's lifetime — so the walk runs once per index,
841
+ * not once per query (`buildSearchStage` is on the search hot path).
842
+ */
843
+ const searchPathsCache = /* @__PURE__ */ new WeakMap();
844
+ function collectSearchPathsCached(index) {
845
+ let cached = searchPathsCache.get(index);
846
+ if (!cached) {
847
+ cached = collectSearchPaths(index.definition.mappings?.fields);
848
+ searchPathsCache.set(index, cached);
849
+ }
850
+ return cached;
851
+ }
852
+ /**
853
+ * Walks an index's nested mapping tree, classifying each searchable leaf by how
854
+ * it must be queried: a plain dotted path (top-level / `document`-nested) vs.
855
+ * scoped under an `embeddedDocuments` array root (queried via `embeddedDocument`).
856
+ * Word-matching for non-array fields rides the wildcard `text` clause, so only
857
+ * autocomplete leaves are tracked outside arrays.
858
+ */
859
+ function collectSearchPaths(fields) {
860
+ const out = {
861
+ autocompleteOutside: [],
862
+ arrayGroups: []
863
+ };
864
+ const byChain = /* @__PURE__ */ new Map();
865
+ const walk = (map, prefix, chain) => {
866
+ if (!map) return;
867
+ for (const [key, mapping] of Object.entries(map)) {
868
+ const path = joinPath(prefix, key);
869
+ const list = Array.isArray(mapping) ? mapping : [mapping];
870
+ const embedded = list.find((m) => m.type === "embeddedDocuments");
871
+ const document = list.find((m) => m.type === "document");
872
+ if (embedded?.fields) walk(embedded.fields, path, [...chain, path]);
873
+ else if (document?.fields) walk(document.fields, path, chain);
874
+ else {
875
+ const isAutocomplete = list.some((m) => m.type === "autocomplete");
876
+ const isText = list.some((m) => m.type === "string");
877
+ if (chain.length > 0) {
878
+ const chainKey = chain.join(">");
879
+ let group = byChain.get(chainKey);
880
+ if (!group) {
881
+ group = {
882
+ chain,
883
+ textLeaves: [],
884
+ autocompleteLeaves: []
885
+ };
886
+ byChain.set(chainKey, group);
887
+ out.arrayGroups.push(group);
888
+ }
889
+ if (isText) group.textLeaves.push(path);
890
+ if (isAutocomplete) group.autocompleteLeaves.push(path);
891
+ } else if (isAutocomplete) out.autocompleteOutside.push(path);
892
+ }
893
+ }
894
+ };
895
+ walk(fields, "", []);
896
+ return out;
821
897
  }
822
898
  /** Builds a $vectorSearch aggregation stage from a pre-computed vector. */
823
899
  function buildVectorSearchStage(host, vector, indexName, limit) {
@@ -1176,11 +1252,8 @@ function arraySafePath(host, physicalPath, logicalPath) {
1176
1252
  for (let i = 0; i < logicalSegments.length; i++) {
1177
1253
  const isLeaf = i === logicalSegments.length - 1;
1178
1254
  out.push(isLeaf ? physicalLeaf : logicalSegments[i]);
1179
- prefix = prefix ? `${prefix}.${logicalSegments[i]}` : logicalSegments[i];
1180
- if (!isLeaf) {
1181
- const type = host._table.flatMap.get(prefix);
1182
- if (type && (0, _atscript_db.resolveDesignType)(type) === "array") out.push("$[]");
1183
- }
1255
+ prefix = joinPath(prefix, logicalSegments[i]);
1256
+ if (!isLeaf && isArrayPath(host._table.flatMap, prefix)) out.push("$[]");
1184
1257
  }
1185
1258
  return out.join(".");
1186
1259
  }
@@ -1190,9 +1263,8 @@ function pathCrossesArray(host, logicalPath) {
1190
1263
  if (segments.length < 2) return false;
1191
1264
  let prefix = "";
1192
1265
  for (let i = 0; i < segments.length - 1; i++) {
1193
- prefix = prefix ? `${prefix}.${segments[i]}` : segments[i];
1194
- const type = host._table.flatMap.get(prefix);
1195
- if (type && (0, _atscript_db.resolveDesignType)(type) === "array") return true;
1266
+ prefix = joinPath(prefix, segments[i]);
1267
+ if (isArrayPath(host._table.flatMap, prefix)) return true;
1196
1268
  }
1197
1269
  return false;
1198
1270
  }
@@ -1440,6 +1512,7 @@ function fieldMappingEqual(a, b) {
1440
1512
  for (const [type, av] of am) {
1441
1513
  const bv = bm.get(type);
1442
1514
  if (!bv || av.analyzer !== bv.analyzer || av.tokenization !== bv.tokenization || av.minGrams !== bv.minGrams || av.maxGrams !== bv.maxGrams || av.foldDiacritics !== bv.foldDiacritics) return false;
1515
+ if (!fieldsMatch(av.fields, bv.fields)) return false;
1443
1516
  }
1444
1517
  return true;
1445
1518
  }
@@ -1484,6 +1557,14 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1484
1557
  _vectorThresholds = /* @__PURE__ */ new Map();
1485
1558
  /** Cached search index lookup. */
1486
1559
  _searchIndexesMap;
1560
+ /**
1561
+ * Search-field mappings recorded during `onFieldScanned`, applied in
1562
+ * `onAfterFlatten`. Deferred because resolving a nested field's container
1563
+ * shape (`document` vs `embeddedDocuments`) needs `this._table.flatMap`, which
1564
+ * is only safe to read once the metadata build is marked complete — i.e. in
1565
+ * `onAfterFlatten`, not mid-scan (the `flatMap` getter would re-enter `build`).
1566
+ */
1567
+ _pendingSearchFields = [];
1487
1568
  /** Physical field names with @db.default.increment → optional start value. */
1488
1569
  _incrementFields = /* @__PURE__ */ new Map();
1489
1570
  /** Physical field names that have a non-binary collation (nocase/unicode). */
@@ -1708,10 +1789,14 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1708
1789
  const startValue = metadata.get("db.default.increment");
1709
1790
  this._incrementFields.set(physicalName, typeof startValue === "number" ? startValue : void 0);
1710
1791
  }
1711
- for (const index of metadata.get("db.mongo.search.text") || []) this._addFieldToSearchIndex("search_text", index.indexName, field, [index.analyzer ? {
1712
- type: "string",
1713
- analyzer: index.analyzer
1714
- } : { type: "string" }]);
1792
+ for (const index of metadata.get("db.mongo.search.text") || []) this._pendingSearchFields.push({
1793
+ indexName: index.indexName,
1794
+ field,
1795
+ mappings: [index.analyzer ? {
1796
+ type: "string",
1797
+ analyzer: index.analyzer
1798
+ } : { type: "string" }]
1799
+ });
1715
1800
  for (const ac of metadata.get("db.mongo.search.autocomplete") || []) {
1716
1801
  const autocomplete = {
1717
1802
  type: "autocomplete",
@@ -1724,7 +1809,11 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1724
1809
  type: "string",
1725
1810
  analyzer: ac.analyzer
1726
1811
  } : { type: "string" };
1727
- this._addFieldToSearchIndex("search_text", ac.indexName, field, [autocomplete, companion]);
1812
+ this._pendingSearchFields.push({
1813
+ indexName: ac.indexName,
1814
+ field,
1815
+ mappings: [autocomplete, companion]
1816
+ });
1728
1817
  }
1729
1818
  const vectorIndex = metadata.get("db.search.vector");
1730
1819
  if (vectorIndex) {
@@ -1766,6 +1855,8 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1766
1855
  };
1767
1856
  }
1768
1857
  onAfterFlatten() {
1858
+ for (const pending of this._pendingSearchFields) this._addFieldToSearchIndex("search_text", pending.indexName, pending.field, pending.mappings);
1859
+ this._pendingSearchFields = [];
1769
1860
  for (const [key, value] of this._vectorFilters.entries()) {
1770
1861
  const index = this._mongoIndexes.get(key);
1771
1862
  if (index && index.type === "vector") index.definition.fields?.push({
@@ -2219,10 +2310,45 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2219
2310
  const name = _name || "DEFAULT";
2220
2311
  let index = this._mongoIndexes.get(mongoIndexKey(type, name));
2221
2312
  if (!index && type === "search_text") index = this._setSearchIndex(type, name, { mappings: { fields: {} } });
2222
- if (index) {
2223
- const fields = index.definition.mappings.fields;
2224
- fields[fieldName] = mergeFieldMappings(fields[fieldName], mappings);
2313
+ if (!index) return;
2314
+ const rootFields = index.definition.mappings.fields;
2315
+ const segments = fieldName.split(".");
2316
+ const topKey = this._table.columnMap.get(segments[0]) ?? segments[0];
2317
+ if (segments.length === 1) {
2318
+ rootFields[topKey] = mergeFieldMappings(rootFields[topKey], mappings);
2319
+ return;
2320
+ }
2321
+ let cursor = rootFields;
2322
+ let prefix = "";
2323
+ for (let i = 0; i < segments.length - 1; i++) {
2324
+ const segment = segments[i];
2325
+ prefix = joinPath(prefix, segment);
2326
+ const nodeType = isArrayPath(this._table.flatMap, prefix) ? "embeddedDocuments" : "document";
2327
+ cursor = this._descendSearchNode(cursor, i === 0 ? topKey : segment, nodeType, fieldName);
2225
2328
  }
2329
+ const leaf = segments[segments.length - 1];
2330
+ cursor[leaf] = mergeFieldMappings(cursor[leaf], mappings);
2331
+ }
2332
+ /**
2333
+ * Returns (creating if needed) the nested `fields` map of the container node
2334
+ * at `segment`, reusing a same-typed sibling node so multiple searchable
2335
+ * fields under one parent (e.g. `identity.name` + `identity.tagline`) merge
2336
+ * instead of clobbering.
2337
+ */
2338
+ _descendSearchNode(fields, segment, nodeType, fullPath) {
2339
+ const existing = fields[segment];
2340
+ if (existing && !Array.isArray(existing) && (existing.type === "document" || existing.type === "embeddedDocuments")) {
2341
+ if (existing.type !== nodeType) this._log(`search index: conflicting container type for "${fullPath}" (${existing.type} vs ${nodeType}); keeping ${existing.type}`);
2342
+ existing.fields ??= {};
2343
+ return existing.fields;
2344
+ }
2345
+ if (existing) this._log(`search index: field "${segment}" used as both value and parent in "${fullPath}"`);
2346
+ const node = {
2347
+ type: nodeType,
2348
+ fields: {}
2349
+ };
2350
+ fields[segment] = node;
2351
+ return node.fields;
2226
2352
  }
2227
2353
  };
2228
2354
  /**
package/dist/index.d.cts CHANGED
@@ -191,6 +191,16 @@ type TVectorSimilarity = "cosine" | "euclidean" | "dotProduct";
191
191
  * `type: "autocomplete"` enables prefix/typeahead (edgeGram) or substring (nGram).
192
192
  * A field may carry several mappings at once (an array of these) — e.g. an
193
193
  * autocomplete field double-mapped as `string` so exact-word hits still rank.
194
+ *
195
+ * Container nodes carry `fields` instead of leaf attributes:
196
+ * - `type: "document"` — a single embedded object (`identity: Identity`). Atlas
197
+ * does NOT accept a dotted mapping key, so each parent object on a nested path
198
+ * must be its own `document` node. Nested fields are reachable by the dotted
199
+ * query path and by a `{ wildcard: "*" }` `text` operator.
200
+ * - `type: "embeddedDocuments"` — an array of objects (`items: Item[]`). Atlas
201
+ * cannot index array-of-object fields under a `document` node; they MUST use
202
+ * `embeddedDocuments` and be queried via the `embeddedDocument` operator
203
+ * (the wildcard `text` operator does not reach them).
194
204
  */
195
205
  interface TSearchFieldMapping {
196
206
  type: string;
@@ -199,6 +209,8 @@ interface TSearchFieldMapping {
199
209
  minGrams?: number;
200
210
  maxGrams?: number;
201
211
  foldDiacritics?: boolean;
212
+ /** Nested mappings for `document` / `embeddedDocuments` container nodes. */
213
+ fields?: Record<string, TSearchFieldMapping | TSearchFieldMapping[]>;
202
214
  }
203
215
  interface TMongoSearchIndexDefinition {
204
216
  mappings?: {
@@ -227,6 +239,18 @@ declare class MongoAdapter extends BaseDbAdapter {
227
239
  protected _vectorThresholds: Map<string, number>;
228
240
  /** Cached search index lookup. */
229
241
  protected _searchIndexesMap?: Map<string, TMongoIndex>;
242
+ /**
243
+ * Search-field mappings recorded during `onFieldScanned`, applied in
244
+ * `onAfterFlatten`. Deferred because resolving a nested field's container
245
+ * shape (`document` vs `embeddedDocuments`) needs `this._table.flatMap`, which
246
+ * is only safe to read once the metadata build is marked complete — i.e. in
247
+ * `onAfterFlatten`, not mid-scan (the `flatMap` getter would re-enter `build`).
248
+ */
249
+ protected _pendingSearchFields: Array<{
250
+ indexName: string | undefined;
251
+ field: string;
252
+ mappings: TSearchFieldMapping[];
253
+ }>;
230
254
  /** Physical field names with @db.default.increment → optional start value. */
231
255
  protected _incrementFields: Map<string, number | undefined>;
232
256
  /** Physical field names that have a non-binary collation (nocase/unicode). */
@@ -408,6 +432,13 @@ declare class MongoAdapter extends BaseDbAdapter {
408
432
  * instead of overwriting one another.
409
433
  */
410
434
  protected _addFieldToSearchIndex(type: TSearchIndex["type"], _name: string | undefined, fieldName: string, mappings: TSearchFieldMapping[]): void;
435
+ /**
436
+ * Returns (creating if needed) the nested `fields` map of the container node
437
+ * at `segment`, reusing a same-typed sibling node so multiple searchable
438
+ * fields under one parent (e.g. `identity.name` + `identity.tagline`) merge
439
+ * instead of clobbering.
440
+ */
441
+ private _descendSearchNode;
411
442
  }
412
443
  //#endregion
413
444
  //#region src/lib/mongo-filter.d.ts
package/dist/index.d.mts CHANGED
@@ -191,6 +191,16 @@ type TVectorSimilarity = "cosine" | "euclidean" | "dotProduct";
191
191
  * `type: "autocomplete"` enables prefix/typeahead (edgeGram) or substring (nGram).
192
192
  * A field may carry several mappings at once (an array of these) — e.g. an
193
193
  * autocomplete field double-mapped as `string` so exact-word hits still rank.
194
+ *
195
+ * Container nodes carry `fields` instead of leaf attributes:
196
+ * - `type: "document"` — a single embedded object (`identity: Identity`). Atlas
197
+ * does NOT accept a dotted mapping key, so each parent object on a nested path
198
+ * must be its own `document` node. Nested fields are reachable by the dotted
199
+ * query path and by a `{ wildcard: "*" }` `text` operator.
200
+ * - `type: "embeddedDocuments"` — an array of objects (`items: Item[]`). Atlas
201
+ * cannot index array-of-object fields under a `document` node; they MUST use
202
+ * `embeddedDocuments` and be queried via the `embeddedDocument` operator
203
+ * (the wildcard `text` operator does not reach them).
194
204
  */
195
205
  interface TSearchFieldMapping {
196
206
  type: string;
@@ -199,6 +209,8 @@ interface TSearchFieldMapping {
199
209
  minGrams?: number;
200
210
  maxGrams?: number;
201
211
  foldDiacritics?: boolean;
212
+ /** Nested mappings for `document` / `embeddedDocuments` container nodes. */
213
+ fields?: Record<string, TSearchFieldMapping | TSearchFieldMapping[]>;
202
214
  }
203
215
  interface TMongoSearchIndexDefinition {
204
216
  mappings?: {
@@ -227,6 +239,18 @@ declare class MongoAdapter extends BaseDbAdapter {
227
239
  protected _vectorThresholds: Map<string, number>;
228
240
  /** Cached search index lookup. */
229
241
  protected _searchIndexesMap?: Map<string, TMongoIndex>;
242
+ /**
243
+ * Search-field mappings recorded during `onFieldScanned`, applied in
244
+ * `onAfterFlatten`. Deferred because resolving a nested field's container
245
+ * shape (`document` vs `embeddedDocuments`) needs `this._table.flatMap`, which
246
+ * is only safe to read once the metadata build is marked complete — i.e. in
247
+ * `onAfterFlatten`, not mid-scan (the `flatMap` getter would re-enter `build`).
248
+ */
249
+ protected _pendingSearchFields: Array<{
250
+ indexName: string | undefined;
251
+ field: string;
252
+ mappings: TSearchFieldMapping[];
253
+ }>;
230
254
  /** Physical field names with @db.default.increment → optional start value. */
231
255
  protected _incrementFields: Map<string, number | undefined>;
232
256
  /** Physical field names that have a non-binary collation (nocase/unicode). */
@@ -408,6 +432,13 @@ declare class MongoAdapter extends BaseDbAdapter {
408
432
  * instead of overwriting one another.
409
433
  */
410
434
  protected _addFieldToSearchIndex(type: TSearchIndex["type"], _name: string | undefined, fieldName: string, mappings: TSearchFieldMapping[]): void;
435
+ /**
436
+ * Returns (creating if needed) the nested `fields` map of the container node
437
+ * at `segment`, reusing a same-typed sibling node so multiple searchable
438
+ * fields under one parent (e.g. `identity.name` + `identity.tagline`) merge
439
+ * instead of clobbering.
440
+ */
441
+ private _descendSearchNode;
411
442
  }
412
443
  //#endregion
413
444
  //#region src/lib/mongo-filter.d.ts
package/dist/index.mjs CHANGED
@@ -2,6 +2,13 @@ import { t as buildMongoFilter } from "./mongo-filter-DceAGI-S.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/path-utils.ts
5
+ function joinPath(prefix, segment) {
6
+ return prefix ? `${prefix}.${segment}` : segment;
7
+ }
8
+ function isArrayPath(flatMap, path) {
9
+ const type = flatMap.get(path);
10
+ return type !== void 0 && resolveDesignType(type) === "array";
11
+ }
5
12
  function hasAncestorIn(path, set) {
6
13
  let dot = path.indexOf(".");
7
14
  while (dot !== -1) {
@@ -156,7 +163,7 @@ var CollectionPatcher = class {
156
163
  * @private
157
164
  */
158
165
  flattenPayload(payload, prefix = "") {
159
- const evalKey = (k) => prefix ? `${prefix}.${k}` : k;
166
+ const evalKey = (k) => joinPath(prefix, k);
160
167
  for (const [_key, value] of Object.entries(payload)) {
161
168
  const key = evalKey(_key);
162
169
  const flatType = this.collection.flatMap.get(key);
@@ -769,27 +776,44 @@ function buildSearchStage(host, text, indexName, controls) {
769
776
  const searchIndex = index;
770
777
  const fuzzy = resolveSearchFuzzy(searchIndex, controls);
771
778
  const strategy = searchIndex.strategy ?? "compound";
772
- const autocompletePaths = autocompleteFieldPaths(searchIndex);
773
- const textClause = () => ({ text: {
779
+ const paths = collectSearchPathsCached(searchIndex);
780
+ const fuzzyOpt = fuzzy ? { fuzzy } : {};
781
+ const textOp = (path) => ({ text: {
774
782
  query: text,
775
- path: { wildcard: "*" },
776
- ...fuzzy ? { fuzzy } : {}
783
+ path,
784
+ ...fuzzyOpt
777
785
  } });
778
- const autocompleteClause = (path) => ({ autocomplete: {
786
+ const autocompleteOp = (path) => ({ autocomplete: {
779
787
  query: text,
780
788
  path,
781
- ...fuzzy ? { fuzzy } : {}
789
+ ...fuzzyOpt
782
790
  } });
783
- let body;
784
- if (strategy === "text" || autocompletePaths.length === 0) body = textClause();
785
- else if (strategy === "autocomplete") {
786
- const clauses = autocompletePaths.map(autocompleteClause);
787
- body = clauses.length === 1 ? clauses[0] : { compound: {
788
- should: clauses,
789
- minimumShouldMatch: 1
791
+ const textClause = () => textOp({ wildcard: "*" });
792
+ const wrapEmbedded = (chain, operator) => {
793
+ let wrapped = operator;
794
+ for (let i = chain.length - 1; i >= 0; i--) wrapped = { embeddedDocument: {
795
+ path: chain[i],
796
+ operator: wrapped
790
797
  } };
791
- } else body = { compound: {
792
- should: [textClause(), ...autocompletePaths.map(autocompleteClause)],
798
+ return wrapped;
799
+ };
800
+ const embeddedTextClause = (chain, leaves) => wrapEmbedded(chain, textOp(leaves.length === 1 ? leaves[0] : leaves));
801
+ const embeddedAutocompleteClause = (chain, leaf) => wrapEmbedded(chain, autocompleteOp(leaf));
802
+ const wantWord = strategy === "text" || strategy === "compound";
803
+ const wantAutocomplete = strategy === "autocomplete" || strategy === "compound";
804
+ const clauses = [];
805
+ if (wantWord) {
806
+ clauses.push(textClause());
807
+ for (const group of paths.arrayGroups) if (group.textLeaves.length > 0) clauses.push(embeddedTextClause(group.chain, group.textLeaves));
808
+ }
809
+ if (wantAutocomplete) {
810
+ for (const path of paths.autocompleteOutside) clauses.push(autocompleteOp(path));
811
+ for (const group of paths.arrayGroups) for (const leaf of group.autocompleteLeaves) clauses.push(embeddedAutocompleteClause(group.chain, leaf));
812
+ }
813
+ let body;
814
+ if (clauses.length <= 1) body = clauses[0] ?? textClause();
815
+ else body = { compound: {
816
+ should: clauses,
793
817
  minimumShouldMatch: 1
794
818
  } };
795
819
  return {
@@ -810,13 +834,65 @@ function resolveSearchFuzzy(index, controls) {
810
834
  const maxEdits = override === void 0 ? index.fuzzy?.maxEdits : Number(override);
811
835
  return maxEdits === 1 || maxEdits === 2 ? { maxEdits } : void 0;
812
836
  }
813
- /** Field paths in this index that carry an `autocomplete` mapping. */
814
- function autocompleteFieldPaths(index) {
815
- const fields = index.definition.mappings?.fields;
816
- if (!fields) return [];
817
- const paths = [];
818
- for (const [path, mapping] of Object.entries(fields)) if ((Array.isArray(mapping) ? mapping : [mapping]).some((m) => m.type === "autocomplete")) paths.push(path);
819
- return paths;
837
+ /**
838
+ * An index's mapping tree is immutable once schema sync builds it, and the index
839
+ * object is cached for the adapter's lifetime — so the walk runs once per index,
840
+ * not once per query (`buildSearchStage` is on the search hot path).
841
+ */
842
+ const searchPathsCache = /* @__PURE__ */ new WeakMap();
843
+ function collectSearchPathsCached(index) {
844
+ let cached = searchPathsCache.get(index);
845
+ if (!cached) {
846
+ cached = collectSearchPaths(index.definition.mappings?.fields);
847
+ searchPathsCache.set(index, cached);
848
+ }
849
+ return cached;
850
+ }
851
+ /**
852
+ * Walks an index's nested mapping tree, classifying each searchable leaf by how
853
+ * it must be queried: a plain dotted path (top-level / `document`-nested) vs.
854
+ * scoped under an `embeddedDocuments` array root (queried via `embeddedDocument`).
855
+ * Word-matching for non-array fields rides the wildcard `text` clause, so only
856
+ * autocomplete leaves are tracked outside arrays.
857
+ */
858
+ function collectSearchPaths(fields) {
859
+ const out = {
860
+ autocompleteOutside: [],
861
+ arrayGroups: []
862
+ };
863
+ const byChain = /* @__PURE__ */ new Map();
864
+ const walk = (map, prefix, chain) => {
865
+ if (!map) return;
866
+ for (const [key, mapping] of Object.entries(map)) {
867
+ const path = joinPath(prefix, key);
868
+ const list = Array.isArray(mapping) ? mapping : [mapping];
869
+ const embedded = list.find((m) => m.type === "embeddedDocuments");
870
+ const document = list.find((m) => m.type === "document");
871
+ if (embedded?.fields) walk(embedded.fields, path, [...chain, path]);
872
+ else if (document?.fields) walk(document.fields, path, chain);
873
+ else {
874
+ const isAutocomplete = list.some((m) => m.type === "autocomplete");
875
+ const isText = list.some((m) => m.type === "string");
876
+ if (chain.length > 0) {
877
+ const chainKey = chain.join(">");
878
+ let group = byChain.get(chainKey);
879
+ if (!group) {
880
+ group = {
881
+ chain,
882
+ textLeaves: [],
883
+ autocompleteLeaves: []
884
+ };
885
+ byChain.set(chainKey, group);
886
+ out.arrayGroups.push(group);
887
+ }
888
+ if (isText) group.textLeaves.push(path);
889
+ if (isAutocomplete) group.autocompleteLeaves.push(path);
890
+ } else if (isAutocomplete) out.autocompleteOutside.push(path);
891
+ }
892
+ }
893
+ };
894
+ walk(fields, "", []);
895
+ return out;
820
896
  }
821
897
  /** Builds a $vectorSearch aggregation stage from a pre-computed vector. */
822
898
  function buildVectorSearchStage(host, vector, indexName, limit) {
@@ -1175,11 +1251,8 @@ function arraySafePath(host, physicalPath, logicalPath) {
1175
1251
  for (let i = 0; i < logicalSegments.length; i++) {
1176
1252
  const isLeaf = i === logicalSegments.length - 1;
1177
1253
  out.push(isLeaf ? physicalLeaf : logicalSegments[i]);
1178
- prefix = prefix ? `${prefix}.${logicalSegments[i]}` : logicalSegments[i];
1179
- if (!isLeaf) {
1180
- const type = host._table.flatMap.get(prefix);
1181
- if (type && resolveDesignType(type) === "array") out.push("$[]");
1182
- }
1254
+ prefix = joinPath(prefix, logicalSegments[i]);
1255
+ if (!isLeaf && isArrayPath(host._table.flatMap, prefix)) out.push("$[]");
1183
1256
  }
1184
1257
  return out.join(".");
1185
1258
  }
@@ -1189,9 +1262,8 @@ function pathCrossesArray(host, logicalPath) {
1189
1262
  if (segments.length < 2) return false;
1190
1263
  let prefix = "";
1191
1264
  for (let i = 0; i < segments.length - 1; i++) {
1192
- prefix = prefix ? `${prefix}.${segments[i]}` : segments[i];
1193
- const type = host._table.flatMap.get(prefix);
1194
- if (type && resolveDesignType(type) === "array") return true;
1265
+ prefix = joinPath(prefix, segments[i]);
1266
+ if (isArrayPath(host._table.flatMap, prefix)) return true;
1195
1267
  }
1196
1268
  return false;
1197
1269
  }
@@ -1439,6 +1511,7 @@ function fieldMappingEqual(a, b) {
1439
1511
  for (const [type, av] of am) {
1440
1512
  const bv = bm.get(type);
1441
1513
  if (!bv || av.analyzer !== bv.analyzer || av.tokenization !== bv.tokenization || av.minGrams !== bv.minGrams || av.maxGrams !== bv.maxGrams || av.foldDiacritics !== bv.foldDiacritics) return false;
1514
+ if (!fieldsMatch(av.fields, bv.fields)) return false;
1442
1515
  }
1443
1516
  return true;
1444
1517
  }
@@ -1483,6 +1556,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1483
1556
  _vectorThresholds = /* @__PURE__ */ new Map();
1484
1557
  /** Cached search index lookup. */
1485
1558
  _searchIndexesMap;
1559
+ /**
1560
+ * Search-field mappings recorded during `onFieldScanned`, applied in
1561
+ * `onAfterFlatten`. Deferred because resolving a nested field's container
1562
+ * shape (`document` vs `embeddedDocuments`) needs `this._table.flatMap`, which
1563
+ * is only safe to read once the metadata build is marked complete — i.e. in
1564
+ * `onAfterFlatten`, not mid-scan (the `flatMap` getter would re-enter `build`).
1565
+ */
1566
+ _pendingSearchFields = [];
1486
1567
  /** Physical field names with @db.default.increment → optional start value. */
1487
1568
  _incrementFields = /* @__PURE__ */ new Map();
1488
1569
  /** Physical field names that have a non-binary collation (nocase/unicode). */
@@ -1707,10 +1788,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1707
1788
  const startValue = metadata.get("db.default.increment");
1708
1789
  this._incrementFields.set(physicalName, typeof startValue === "number" ? startValue : void 0);
1709
1790
  }
1710
- for (const index of metadata.get("db.mongo.search.text") || []) this._addFieldToSearchIndex("search_text", index.indexName, field, [index.analyzer ? {
1711
- type: "string",
1712
- analyzer: index.analyzer
1713
- } : { type: "string" }]);
1791
+ for (const index of metadata.get("db.mongo.search.text") || []) this._pendingSearchFields.push({
1792
+ indexName: index.indexName,
1793
+ field,
1794
+ mappings: [index.analyzer ? {
1795
+ type: "string",
1796
+ analyzer: index.analyzer
1797
+ } : { type: "string" }]
1798
+ });
1714
1799
  for (const ac of metadata.get("db.mongo.search.autocomplete") || []) {
1715
1800
  const autocomplete = {
1716
1801
  type: "autocomplete",
@@ -1723,7 +1808,11 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1723
1808
  type: "string",
1724
1809
  analyzer: ac.analyzer
1725
1810
  } : { type: "string" };
1726
- this._addFieldToSearchIndex("search_text", ac.indexName, field, [autocomplete, companion]);
1811
+ this._pendingSearchFields.push({
1812
+ indexName: ac.indexName,
1813
+ field,
1814
+ mappings: [autocomplete, companion]
1815
+ });
1727
1816
  }
1728
1817
  const vectorIndex = metadata.get("db.search.vector");
1729
1818
  if (vectorIndex) {
@@ -1765,6 +1854,8 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1765
1854
  };
1766
1855
  }
1767
1856
  onAfterFlatten() {
1857
+ for (const pending of this._pendingSearchFields) this._addFieldToSearchIndex("search_text", pending.indexName, pending.field, pending.mappings);
1858
+ this._pendingSearchFields = [];
1768
1859
  for (const [key, value] of this._vectorFilters.entries()) {
1769
1860
  const index = this._mongoIndexes.get(key);
1770
1861
  if (index && index.type === "vector") index.definition.fields?.push({
@@ -2218,10 +2309,45 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2218
2309
  const name = _name || "DEFAULT";
2219
2310
  let index = this._mongoIndexes.get(mongoIndexKey(type, name));
2220
2311
  if (!index && type === "search_text") index = this._setSearchIndex(type, name, { mappings: { fields: {} } });
2221
- if (index) {
2222
- const fields = index.definition.mappings.fields;
2223
- fields[fieldName] = mergeFieldMappings(fields[fieldName], mappings);
2312
+ if (!index) return;
2313
+ const rootFields = index.definition.mappings.fields;
2314
+ const segments = fieldName.split(".");
2315
+ const topKey = this._table.columnMap.get(segments[0]) ?? segments[0];
2316
+ if (segments.length === 1) {
2317
+ rootFields[topKey] = mergeFieldMappings(rootFields[topKey], mappings);
2318
+ return;
2319
+ }
2320
+ let cursor = rootFields;
2321
+ let prefix = "";
2322
+ for (let i = 0; i < segments.length - 1; i++) {
2323
+ const segment = segments[i];
2324
+ prefix = joinPath(prefix, segment);
2325
+ const nodeType = isArrayPath(this._table.flatMap, prefix) ? "embeddedDocuments" : "document";
2326
+ cursor = this._descendSearchNode(cursor, i === 0 ? topKey : segment, nodeType, fieldName);
2224
2327
  }
2328
+ const leaf = segments[segments.length - 1];
2329
+ cursor[leaf] = mergeFieldMappings(cursor[leaf], mappings);
2330
+ }
2331
+ /**
2332
+ * Returns (creating if needed) the nested `fields` map of the container node
2333
+ * at `segment`, reusing a same-typed sibling node so multiple searchable
2334
+ * fields under one parent (e.g. `identity.name` + `identity.tagline`) merge
2335
+ * instead of clobbering.
2336
+ */
2337
+ _descendSearchNode(fields, segment, nodeType, fullPath) {
2338
+ const existing = fields[segment];
2339
+ if (existing && !Array.isArray(existing) && (existing.type === "document" || existing.type === "embeddedDocuments")) {
2340
+ if (existing.type !== nodeType) this._log(`search index: conflicting container type for "${fullPath}" (${existing.type} vs ${nodeType}); keeping ${existing.type}`);
2341
+ existing.fields ??= {};
2342
+ return existing.fields;
2343
+ }
2344
+ if (existing) this._log(`search index: field "${segment}" used as both value and parent in "${fullPath}"`);
2345
+ const node = {
2346
+ type: nodeType,
2347
+ fields: {}
2348
+ };
2349
+ fields[segment] = node;
2350
+ return node.fields;
2225
2351
  }
2226
2352
  };
2227
2353
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-mongo",
3
- "version": "0.1.107",
3
+ "version": "0.1.109",
4
4
  "description": "Mongodb plugin for atscript.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -46,17 +46,17 @@
46
46
  "access": "public"
47
47
  },
48
48
  "devDependencies": {
49
- "@atscript/core": "^0.1.77",
50
- "@atscript/typescript": "^0.1.77",
49
+ "@atscript/core": "^0.1.78",
50
+ "@atscript/typescript": "^0.1.78",
51
51
  "mongodb": "^6.17.0",
52
52
  "mongodb-memory-server-core": "^10.0.0",
53
- "unplugin-atscript": "^0.1.77"
53
+ "unplugin-atscript": "^0.1.78"
54
54
  },
55
55
  "peerDependencies": {
56
- "@atscript/core": "^0.1.77",
57
- "@atscript/typescript": "^0.1.77",
56
+ "@atscript/core": "^0.1.78",
57
+ "@atscript/typescript": "^0.1.78",
58
58
  "mongodb": "^6.17.0",
59
- "@atscript/db": "^0.1.107"
59
+ "@atscript/db": "^0.1.109"
60
60
  },
61
61
  "scripts": {
62
62
  "postinstall": "asc -f dts",