@atscript/db-mongo 0.1.106 → 0.1.108

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
@@ -2,15 +2,29 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  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
+ //#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
+ }
13
+ function hasAncestorIn(path, set) {
14
+ let dot = path.indexOf(".");
15
+ while (dot !== -1) {
16
+ if (set.has(path.slice(0, dot))) return true;
17
+ dot = path.indexOf(".", dot + 1);
18
+ }
19
+ return false;
20
+ }
21
+ //#endregion
5
22
  //#region src/lib/projection-dedupe.ts
6
23
  function dedupeProjection(projection) {
7
24
  const includeKeys = Object.keys(projection).filter((k) => projection[k] === 1);
8
25
  if (includeKeys.length < 2) return projection;
9
- const toRemove = /* @__PURE__ */ new Set();
10
- for (const parent of includeKeys) {
11
- const prefix = parent + ".";
12
- for (const other of includeKeys) if (other !== parent && other.startsWith(prefix)) toRemove.add(other);
13
- }
26
+ const includes = new Set(includeKeys);
27
+ const toRemove = new Set(includeKeys.filter((k) => hasAncestorIn(k, includes)));
14
28
  if (toRemove.size === 0) return projection;
15
29
  const result = {};
16
30
  for (const k of Object.keys(projection)) if (!toRemove.has(k)) result[k] = projection[k];
@@ -150,7 +164,7 @@ var CollectionPatcher = class {
150
164
  * @private
151
165
  */
152
166
  flattenPayload(payload, prefix = "") {
153
- const evalKey = (k) => prefix ? `${prefix}.${k}` : k;
167
+ const evalKey = (k) => joinPath(prefix, k);
154
168
  for (const [_key, value] of Object.entries(payload)) {
155
169
  const key = evalKey(_key);
156
170
  const flatType = this.collection.flatMap.get(key);
@@ -763,27 +777,44 @@ function buildSearchStage(host, text, indexName, controls) {
763
777
  const searchIndex = index;
764
778
  const fuzzy = resolveSearchFuzzy(searchIndex, controls);
765
779
  const strategy = searchIndex.strategy ?? "compound";
766
- const autocompletePaths = autocompleteFieldPaths(searchIndex);
767
- const textClause = () => ({ text: {
780
+ const paths = collectSearchPathsCached(searchIndex);
781
+ const fuzzyOpt = fuzzy ? { fuzzy } : {};
782
+ const textOp = (path) => ({ text: {
768
783
  query: text,
769
- path: { wildcard: "*" },
770
- ...fuzzy ? { fuzzy } : {}
784
+ path,
785
+ ...fuzzyOpt
771
786
  } });
772
- const autocompleteClause = (path) => ({ autocomplete: {
787
+ const autocompleteOp = (path) => ({ autocomplete: {
773
788
  query: text,
774
789
  path,
775
- ...fuzzy ? { fuzzy } : {}
790
+ ...fuzzyOpt
776
791
  } });
777
- let body;
778
- if (strategy === "text" || autocompletePaths.length === 0) body = textClause();
779
- else if (strategy === "autocomplete") {
780
- const clauses = autocompletePaths.map(autocompleteClause);
781
- body = clauses.length === 1 ? clauses[0] : { compound: {
782
- should: clauses,
783
- 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
784
798
  } };
785
- } else body = { compound: {
786
- 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,
787
818
  minimumShouldMatch: 1
788
819
  } };
789
820
  return {
@@ -804,13 +835,65 @@ function resolveSearchFuzzy(index, controls) {
804
835
  const maxEdits = override === void 0 ? index.fuzzy?.maxEdits : Number(override);
805
836
  return maxEdits === 1 || maxEdits === 2 ? { maxEdits } : void 0;
806
837
  }
807
- /** Field paths in this index that carry an `autocomplete` mapping. */
808
- function autocompleteFieldPaths(index) {
809
- const fields = index.definition.mappings?.fields;
810
- if (!fields) return [];
811
- const paths = [];
812
- for (const [path, mapping] of Object.entries(fields)) if ((Array.isArray(mapping) ? mapping : [mapping]).some((m) => m.type === "autocomplete")) paths.push(path);
813
- 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;
814
897
  }
815
898
  /** Builds a $vectorSearch aggregation stage from a pre-computed vector. */
816
899
  function buildVectorSearchStage(host, vector, indexName, limit) {
@@ -1144,8 +1227,10 @@ async function syncColumnsImpl(host, diff) {
1144
1227
  }
1145
1228
  async function dropColumnsImpl(host, columns) {
1146
1229
  if (columns.length === 0) return;
1230
+ const dropped = new Set(columns);
1231
+ const minimal = columns.filter((col) => !hasAncestorIn(col, dropped));
1147
1232
  const unsetSpec = {};
1148
- for (const col of columns) unsetSpec[arraySafePath(host, col, col)] = "";
1233
+ for (const col of minimal) unsetSpec[arraySafePath(host, col, col)] = "";
1149
1234
  await host.collection.updateMany({}, { $unset: unsetSpec }, host._getSessionOpts());
1150
1235
  }
1151
1236
  /**
@@ -1167,11 +1252,8 @@ function arraySafePath(host, physicalPath, logicalPath) {
1167
1252
  for (let i = 0; i < logicalSegments.length; i++) {
1168
1253
  const isLeaf = i === logicalSegments.length - 1;
1169
1254
  out.push(isLeaf ? physicalLeaf : logicalSegments[i]);
1170
- prefix = prefix ? `${prefix}.${logicalSegments[i]}` : logicalSegments[i];
1171
- if (!isLeaf) {
1172
- const type = host._table.flatMap.get(prefix);
1173
- if (type && (0, _atscript_db.resolveDesignType)(type) === "array") out.push("$[]");
1174
- }
1255
+ prefix = joinPath(prefix, logicalSegments[i]);
1256
+ if (!isLeaf && isArrayPath(host._table.flatMap, prefix)) out.push("$[]");
1175
1257
  }
1176
1258
  return out.join(".");
1177
1259
  }
@@ -1181,9 +1263,8 @@ function pathCrossesArray(host, logicalPath) {
1181
1263
  if (segments.length < 2) return false;
1182
1264
  let prefix = "";
1183
1265
  for (let i = 0; i < segments.length - 1; i++) {
1184
- prefix = prefix ? `${prefix}.${segments[i]}` : segments[i];
1185
- const type = host._table.flatMap.get(prefix);
1186
- 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;
1187
1268
  }
1188
1269
  return false;
1189
1270
  }
@@ -1431,6 +1512,7 @@ function fieldMappingEqual(a, b) {
1431
1512
  for (const [type, av] of am) {
1432
1513
  const bv = bm.get(type);
1433
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;
1434
1516
  }
1435
1517
  return true;
1436
1518
  }
@@ -1475,6 +1557,14 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1475
1557
  _vectorThresholds = /* @__PURE__ */ new Map();
1476
1558
  /** Cached search index lookup. */
1477
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 = [];
1478
1568
  /** Physical field names with @db.default.increment → optional start value. */
1479
1569
  _incrementFields = /* @__PURE__ */ new Map();
1480
1570
  /** Physical field names that have a non-binary collation (nocase/unicode). */
@@ -1699,10 +1789,14 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1699
1789
  const startValue = metadata.get("db.default.increment");
1700
1790
  this._incrementFields.set(physicalName, typeof startValue === "number" ? startValue : void 0);
1701
1791
  }
1702
- for (const index of metadata.get("db.mongo.search.text") || []) this._addFieldToSearchIndex("search_text", index.indexName, field, [index.analyzer ? {
1703
- type: "string",
1704
- analyzer: index.analyzer
1705
- } : { 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
+ });
1706
1800
  for (const ac of metadata.get("db.mongo.search.autocomplete") || []) {
1707
1801
  const autocomplete = {
1708
1802
  type: "autocomplete",
@@ -1715,7 +1809,11 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1715
1809
  type: "string",
1716
1810
  analyzer: ac.analyzer
1717
1811
  } : { type: "string" };
1718
- this._addFieldToSearchIndex("search_text", ac.indexName, field, [autocomplete, companion]);
1812
+ this._pendingSearchFields.push({
1813
+ indexName: ac.indexName,
1814
+ field,
1815
+ mappings: [autocomplete, companion]
1816
+ });
1719
1817
  }
1720
1818
  const vectorIndex = metadata.get("db.search.vector");
1721
1819
  if (vectorIndex) {
@@ -1757,6 +1855,8 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1757
1855
  };
1758
1856
  }
1759
1857
  onAfterFlatten() {
1858
+ for (const pending of this._pendingSearchFields) this._addFieldToSearchIndex("search_text", pending.indexName, pending.field, pending.mappings);
1859
+ this._pendingSearchFields = [];
1760
1860
  for (const [key, value] of this._vectorFilters.entries()) {
1761
1861
  const index = this._mongoIndexes.get(key);
1762
1862
  if (index && index.type === "vector") index.definition.fields?.push({
@@ -2210,10 +2310,45 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
2210
2310
  const name = _name || "DEFAULT";
2211
2311
  let index = this._mongoIndexes.get(mongoIndexKey(type, name));
2212
2312
  if (!index && type === "search_text") index = this._setSearchIndex(type, name, { mappings: { fields: {} } });
2213
- if (index) {
2214
- const fields = index.definition.mappings.fields;
2215
- 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;
2216
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);
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;
2217
2352
  }
2218
2353
  };
2219
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
@@ -1,15 +1,29 @@
1
1
  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
+ //#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
+ }
12
+ function hasAncestorIn(path, set) {
13
+ let dot = path.indexOf(".");
14
+ while (dot !== -1) {
15
+ if (set.has(path.slice(0, dot))) return true;
16
+ dot = path.indexOf(".", dot + 1);
17
+ }
18
+ return false;
19
+ }
20
+ //#endregion
4
21
  //#region src/lib/projection-dedupe.ts
5
22
  function dedupeProjection(projection) {
6
23
  const includeKeys = Object.keys(projection).filter((k) => projection[k] === 1);
7
24
  if (includeKeys.length < 2) return projection;
8
- const toRemove = /* @__PURE__ */ new Set();
9
- for (const parent of includeKeys) {
10
- const prefix = parent + ".";
11
- for (const other of includeKeys) if (other !== parent && other.startsWith(prefix)) toRemove.add(other);
12
- }
25
+ const includes = new Set(includeKeys);
26
+ const toRemove = new Set(includeKeys.filter((k) => hasAncestorIn(k, includes)));
13
27
  if (toRemove.size === 0) return projection;
14
28
  const result = {};
15
29
  for (const k of Object.keys(projection)) if (!toRemove.has(k)) result[k] = projection[k];
@@ -149,7 +163,7 @@ var CollectionPatcher = class {
149
163
  * @private
150
164
  */
151
165
  flattenPayload(payload, prefix = "") {
152
- const evalKey = (k) => prefix ? `${prefix}.${k}` : k;
166
+ const evalKey = (k) => joinPath(prefix, k);
153
167
  for (const [_key, value] of Object.entries(payload)) {
154
168
  const key = evalKey(_key);
155
169
  const flatType = this.collection.flatMap.get(key);
@@ -762,27 +776,44 @@ function buildSearchStage(host, text, indexName, controls) {
762
776
  const searchIndex = index;
763
777
  const fuzzy = resolveSearchFuzzy(searchIndex, controls);
764
778
  const strategy = searchIndex.strategy ?? "compound";
765
- const autocompletePaths = autocompleteFieldPaths(searchIndex);
766
- const textClause = () => ({ text: {
779
+ const paths = collectSearchPathsCached(searchIndex);
780
+ const fuzzyOpt = fuzzy ? { fuzzy } : {};
781
+ const textOp = (path) => ({ text: {
767
782
  query: text,
768
- path: { wildcard: "*" },
769
- ...fuzzy ? { fuzzy } : {}
783
+ path,
784
+ ...fuzzyOpt
770
785
  } });
771
- const autocompleteClause = (path) => ({ autocomplete: {
786
+ const autocompleteOp = (path) => ({ autocomplete: {
772
787
  query: text,
773
788
  path,
774
- ...fuzzy ? { fuzzy } : {}
789
+ ...fuzzyOpt
775
790
  } });
776
- let body;
777
- if (strategy === "text" || autocompletePaths.length === 0) body = textClause();
778
- else if (strategy === "autocomplete") {
779
- const clauses = autocompletePaths.map(autocompleteClause);
780
- body = clauses.length === 1 ? clauses[0] : { compound: {
781
- should: clauses,
782
- 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
783
797
  } };
784
- } else body = { compound: {
785
- 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,
786
817
  minimumShouldMatch: 1
787
818
  } };
788
819
  return {
@@ -803,13 +834,65 @@ function resolveSearchFuzzy(index, controls) {
803
834
  const maxEdits = override === void 0 ? index.fuzzy?.maxEdits : Number(override);
804
835
  return maxEdits === 1 || maxEdits === 2 ? { maxEdits } : void 0;
805
836
  }
806
- /** Field paths in this index that carry an `autocomplete` mapping. */
807
- function autocompleteFieldPaths(index) {
808
- const fields = index.definition.mappings?.fields;
809
- if (!fields) return [];
810
- const paths = [];
811
- for (const [path, mapping] of Object.entries(fields)) if ((Array.isArray(mapping) ? mapping : [mapping]).some((m) => m.type === "autocomplete")) paths.push(path);
812
- 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;
813
896
  }
814
897
  /** Builds a $vectorSearch aggregation stage from a pre-computed vector. */
815
898
  function buildVectorSearchStage(host, vector, indexName, limit) {
@@ -1143,8 +1226,10 @@ async function syncColumnsImpl(host, diff) {
1143
1226
  }
1144
1227
  async function dropColumnsImpl(host, columns) {
1145
1228
  if (columns.length === 0) return;
1229
+ const dropped = new Set(columns);
1230
+ const minimal = columns.filter((col) => !hasAncestorIn(col, dropped));
1146
1231
  const unsetSpec = {};
1147
- for (const col of columns) unsetSpec[arraySafePath(host, col, col)] = "";
1232
+ for (const col of minimal) unsetSpec[arraySafePath(host, col, col)] = "";
1148
1233
  await host.collection.updateMany({}, { $unset: unsetSpec }, host._getSessionOpts());
1149
1234
  }
1150
1235
  /**
@@ -1166,11 +1251,8 @@ function arraySafePath(host, physicalPath, logicalPath) {
1166
1251
  for (let i = 0; i < logicalSegments.length; i++) {
1167
1252
  const isLeaf = i === logicalSegments.length - 1;
1168
1253
  out.push(isLeaf ? physicalLeaf : logicalSegments[i]);
1169
- prefix = prefix ? `${prefix}.${logicalSegments[i]}` : logicalSegments[i];
1170
- if (!isLeaf) {
1171
- const type = host._table.flatMap.get(prefix);
1172
- if (type && resolveDesignType(type) === "array") out.push("$[]");
1173
- }
1254
+ prefix = joinPath(prefix, logicalSegments[i]);
1255
+ if (!isLeaf && isArrayPath(host._table.flatMap, prefix)) out.push("$[]");
1174
1256
  }
1175
1257
  return out.join(".");
1176
1258
  }
@@ -1180,9 +1262,8 @@ function pathCrossesArray(host, logicalPath) {
1180
1262
  if (segments.length < 2) return false;
1181
1263
  let prefix = "";
1182
1264
  for (let i = 0; i < segments.length - 1; i++) {
1183
- prefix = prefix ? `${prefix}.${segments[i]}` : segments[i];
1184
- const type = host._table.flatMap.get(prefix);
1185
- if (type && resolveDesignType(type) === "array") return true;
1265
+ prefix = joinPath(prefix, segments[i]);
1266
+ if (isArrayPath(host._table.flatMap, prefix)) return true;
1186
1267
  }
1187
1268
  return false;
1188
1269
  }
@@ -1430,6 +1511,7 @@ function fieldMappingEqual(a, b) {
1430
1511
  for (const [type, av] of am) {
1431
1512
  const bv = bm.get(type);
1432
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;
1433
1515
  }
1434
1516
  return true;
1435
1517
  }
@@ -1474,6 +1556,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1474
1556
  _vectorThresholds = /* @__PURE__ */ new Map();
1475
1557
  /** Cached search index lookup. */
1476
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 = [];
1477
1567
  /** Physical field names with @db.default.increment → optional start value. */
1478
1568
  _incrementFields = /* @__PURE__ */ new Map();
1479
1569
  /** Physical field names that have a non-binary collation (nocase/unicode). */
@@ -1698,10 +1788,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1698
1788
  const startValue = metadata.get("db.default.increment");
1699
1789
  this._incrementFields.set(physicalName, typeof startValue === "number" ? startValue : void 0);
1700
1790
  }
1701
- for (const index of metadata.get("db.mongo.search.text") || []) this._addFieldToSearchIndex("search_text", index.indexName, field, [index.analyzer ? {
1702
- type: "string",
1703
- analyzer: index.analyzer
1704
- } : { 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
+ });
1705
1799
  for (const ac of metadata.get("db.mongo.search.autocomplete") || []) {
1706
1800
  const autocomplete = {
1707
1801
  type: "autocomplete",
@@ -1714,7 +1808,11 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1714
1808
  type: "string",
1715
1809
  analyzer: ac.analyzer
1716
1810
  } : { type: "string" };
1717
- this._addFieldToSearchIndex("search_text", ac.indexName, field, [autocomplete, companion]);
1811
+ this._pendingSearchFields.push({
1812
+ indexName: ac.indexName,
1813
+ field,
1814
+ mappings: [autocomplete, companion]
1815
+ });
1718
1816
  }
1719
1817
  const vectorIndex = metadata.get("db.search.vector");
1720
1818
  if (vectorIndex) {
@@ -1756,6 +1854,8 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1756
1854
  };
1757
1855
  }
1758
1856
  onAfterFlatten() {
1857
+ for (const pending of this._pendingSearchFields) this._addFieldToSearchIndex("search_text", pending.indexName, pending.field, pending.mappings);
1858
+ this._pendingSearchFields = [];
1759
1859
  for (const [key, value] of this._vectorFilters.entries()) {
1760
1860
  const index = this._mongoIndexes.get(key);
1761
1861
  if (index && index.type === "vector") index.definition.fields?.push({
@@ -2209,10 +2309,45 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
2209
2309
  const name = _name || "DEFAULT";
2210
2310
  let index = this._mongoIndexes.get(mongoIndexKey(type, name));
2211
2311
  if (!index && type === "search_text") index = this._setSearchIndex(type, name, { mappings: { fields: {} } });
2212
- if (index) {
2213
- const fields = index.definition.mappings.fields;
2214
- 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;
2215
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);
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;
2216
2351
  }
2217
2352
  };
2218
2353
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-mongo",
3
- "version": "0.1.106",
3
+ "version": "0.1.108",
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.76",
50
- "@atscript/typescript": "^0.1.76",
49
+ "@atscript/core": "^0.1.77",
50
+ "@atscript/typescript": "^0.1.77",
51
51
  "mongodb": "^6.17.0",
52
52
  "mongodb-memory-server-core": "^10.0.0",
53
- "unplugin-atscript": "^0.1.76"
53
+ "unplugin-atscript": "^0.1.77"
54
54
  },
55
55
  "peerDependencies": {
56
- "@atscript/core": "^0.1.76",
57
- "@atscript/typescript": "^0.1.76",
56
+ "@atscript/core": "^0.1.77",
57
+ "@atscript/typescript": "^0.1.77",
58
58
  "mongodb": "^6.17.0",
59
- "@atscript/db": "^0.1.106"
59
+ "@atscript/db": "^0.1.108"
60
60
  },
61
61
  "scripts": {
62
62
  "postinstall": "asc -f dts",