@atscript/db-mongo 0.1.129 → 0.1.131

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
@@ -37,14 +37,19 @@ function groupIdKey(field, index) {
37
37
  return field.includes(".") ? `k${index}` : field;
38
38
  }
39
39
  /**
40
- * Builds the common prefix stages: $match + $group._id from groupBy fields.
41
- * Shared by both full aggregate and count pipelines.
40
+ * Builds the common prefix stages: [search] + $match + $group._id from groupBy
41
+ * fields. Shared by both full aggregate and count pipelines.
42
42
  * `groupKeys` maps each `$groupBy` path to its `_id` sub-key.
43
+ *
44
+ * `searchStage` is the resolved text-search stage (classic `$text` `$match`, or
45
+ * an Atlas `$search`). Both MUST be the pipeline's FIRST stage, hence its
46
+ * position in front of the filter `$match`. Resolving it needs adapter state,
47
+ * so the caller passes it in and this module stays a pure translation.
43
48
  */
44
- function buildPrefix(query) {
49
+ function buildPrefix(query, searchStage) {
45
50
  const controls = query.controls || {};
46
51
  const groupBy = controls.$groupBy ?? [];
47
- const pipeline = [{ $match: require_mongo_filter.buildMongoFilter(query.filter) }];
52
+ const pipeline = searchStage ? [searchStage, { $match: require_mongo_filter.buildMongoFilter(query.filter) }] : [{ $match: require_mongo_filter.buildMongoFilter(query.filter) }];
48
53
  const groupId = {};
49
54
  const groupKeys = [];
50
55
  for (const [index, field] of groupBy.entries()) {
@@ -60,17 +65,19 @@ function buildPrefix(query) {
60
65
  };
61
66
  }
62
67
  /**
63
- * The stages every grouped query shares: `$match(filter)` → `$group`
64
- * (dimensions + accumulators) → `$project` (flatten `_id`, keep aliases) →
65
- * `$match($having)`. The row pipeline appends sort/skip/limit, the count
66
- * pipeline appends `$count`, so both see exactly the same group set.
68
+ * The stages every grouped query shares: `[search →] $match(filter)` →
69
+ * `$group` (dimensions + accumulators) → `$project` (flatten `_id`, keep
70
+ * aliases) → `$match($having)`. The row pipeline appends sort/skip/limit, the
71
+ * count pipeline appends `$count`, so both see exactly the same group set —
72
+ * including the same `$search` narrowing, which is why the stage is threaded
73
+ * through this single seam instead of being appended by each caller.
67
74
  *
68
75
  * With `accumulators: false` only the `$group._id` dimensions are emitted
69
76
  * (no accumulators, no `$project`, no `$having`) — the cheapest shape for a
70
77
  * plain group count, where no alias has to be resolvable.
71
78
  */
72
- function buildGroupedStages(query, { accumulators }) {
73
- const { pipeline, groupId, groupKeys, controls } = buildPrefix(query);
79
+ function buildGroupedStages(query, { accumulators, searchStage }) {
80
+ const { pipeline, groupId, groupKeys, controls } = buildPrefix(query, searchStage);
74
81
  const groupStage = { _id: groupId };
75
82
  if (!accumulators) {
76
83
  pipeline.push({ $group: groupStage });
@@ -97,10 +104,18 @@ function buildGroupedStages(query, { accumulators }) {
97
104
  /**
98
105
  * Builds a full MongoDB aggregation pipeline for GROUP BY queries.
99
106
  *
100
- * Pipeline: $match → $group → $project → $match(having) → $sort → $skip → $limit
107
+ * Pipeline: [search →] $match → $group → $project → $match(having) → $sort →
108
+ * $skip → $limit
109
+ *
110
+ * `searchStage` is the resolved `$search` / `$text` stage (see
111
+ * `buildAggregateSearchStage`); unlike the leaf runner this path adds no
112
+ * relevance `$sort` and no default `$limit`.
101
113
  */
102
- function buildAggregatePipeline(query) {
103
- const { pipeline, controls } = buildGroupedStages(query, { accumulators: true });
114
+ function buildAggregatePipeline(query, searchStage) {
115
+ const { pipeline, controls } = buildGroupedStages(query, {
116
+ accumulators: true,
117
+ searchStage
118
+ });
104
119
  if (controls.$sort) pipeline.push({ $sort: controls.$sort });
105
120
  if (controls.$skip) pipeline.push({ $skip: controls.$skip });
106
121
  if (controls.$limit) pipeline.push({ $limit: controls.$limit });
@@ -113,10 +128,16 @@ function buildAggregatePipeline(query) {
113
128
  * `$having` has a value to match; without it only the `$group._id`
114
129
  * dimensions are needed.
115
130
  *
116
- * Pipeline: $match → $group → [$project → $match(having)] → $count
131
+ * Pipeline: [search →] $match → $group → [$project → $match(having)] → $count
132
+ *
133
+ * `searchStage` must be the SAME stage handed to `buildAggregatePipeline` for
134
+ * the same query — the counted groups are exactly the rows the search matched.
117
135
  */
118
- function buildCountPipeline(query) {
119
- const { pipeline } = buildGroupedStages(query, { accumulators: Boolean(query.controls?.$having) });
136
+ function buildCountPipeline(query, searchStage) {
137
+ const { pipeline } = buildGroupedStages(query, {
138
+ accumulators: Boolean(query.controls?.$having),
139
+ searchStage
140
+ });
120
141
  pipeline.push({ $count: "count" });
121
142
  return pipeline;
122
143
  }
package/dist/agg.d.cts CHANGED
@@ -5,9 +5,14 @@ import { Document } from "mongodb";
5
5
  /**
6
6
  * Builds a full MongoDB aggregation pipeline for GROUP BY queries.
7
7
  *
8
- * Pipeline: $match → $group → $project → $match(having) → $sort → $skip → $limit
8
+ * Pipeline: [search →] $match → $group → $project → $match(having) → $sort →
9
+ * $skip → $limit
10
+ *
11
+ * `searchStage` is the resolved `$search` / `$text` stage (see
12
+ * `buildAggregateSearchStage`); unlike the leaf runner this path adds no
13
+ * relevance `$sort` and no default `$limit`.
9
14
  */
10
- declare function buildAggregatePipeline(query: DbQuery): Document[];
15
+ declare function buildAggregatePipeline(query: DbQuery, searchStage?: Document): Document[];
11
16
  /**
12
17
  * Builds a count-only pipeline: the number of groups that survive `$having`
13
18
  * (all groups when there is none). With `$having` it runs the same grouped
@@ -15,8 +20,11 @@ declare function buildAggregatePipeline(query: DbQuery): Document[];
15
20
  * `$having` has a value to match; without it only the `$group._id`
16
21
  * dimensions are needed.
17
22
  *
18
- * Pipeline: $match → $group → [$project → $match(having)] → $count
23
+ * Pipeline: [search →] $match → $group → [$project → $match(having)] → $count
24
+ *
25
+ * `searchStage` must be the SAME stage handed to `buildAggregatePipeline` for
26
+ * the same query — the counted groups are exactly the rows the search matched.
19
27
  */
20
- declare function buildCountPipeline(query: DbQuery): Document[];
28
+ declare function buildCountPipeline(query: DbQuery, searchStage?: Document): Document[];
21
29
  //#endregion
22
30
  export { buildAggregatePipeline, buildCountPipeline };
package/dist/agg.d.mts CHANGED
@@ -5,9 +5,14 @@ import { Document } from "mongodb";
5
5
  /**
6
6
  * Builds a full MongoDB aggregation pipeline for GROUP BY queries.
7
7
  *
8
- * Pipeline: $match → $group → $project → $match(having) → $sort → $skip → $limit
8
+ * Pipeline: [search →] $match → $group → $project → $match(having) → $sort →
9
+ * $skip → $limit
10
+ *
11
+ * `searchStage` is the resolved `$search` / `$text` stage (see
12
+ * `buildAggregateSearchStage`); unlike the leaf runner this path adds no
13
+ * relevance `$sort` and no default `$limit`.
9
14
  */
10
- declare function buildAggregatePipeline(query: DbQuery): Document[];
15
+ declare function buildAggregatePipeline(query: DbQuery, searchStage?: Document): Document[];
11
16
  /**
12
17
  * Builds a count-only pipeline: the number of groups that survive `$having`
13
18
  * (all groups when there is none). With `$having` it runs the same grouped
@@ -15,8 +20,11 @@ declare function buildAggregatePipeline(query: DbQuery): Document[];
15
20
  * `$having` has a value to match; without it only the `$group._id`
16
21
  * dimensions are needed.
17
22
  *
18
- * Pipeline: $match → $group → [$project → $match(having)] → $count
23
+ * Pipeline: [search →] $match → $group → [$project → $match(having)] → $count
24
+ *
25
+ * `searchStage` must be the SAME stage handed to `buildAggregatePipeline` for
26
+ * the same query — the counted groups are exactly the rows the search matched.
19
27
  */
20
- declare function buildCountPipeline(query: DbQuery): Document[];
28
+ declare function buildCountPipeline(query: DbQuery, searchStage?: Document): Document[];
21
29
  //#endregion
22
30
  export { buildAggregatePipeline, buildCountPipeline };
package/dist/agg.mjs CHANGED
@@ -36,14 +36,19 @@ function groupIdKey(field, index) {
36
36
  return field.includes(".") ? `k${index}` : field;
37
37
  }
38
38
  /**
39
- * Builds the common prefix stages: $match + $group._id from groupBy fields.
40
- * Shared by both full aggregate and count pipelines.
39
+ * Builds the common prefix stages: [search] + $match + $group._id from groupBy
40
+ * fields. Shared by both full aggregate and count pipelines.
41
41
  * `groupKeys` maps each `$groupBy` path to its `_id` sub-key.
42
+ *
43
+ * `searchStage` is the resolved text-search stage (classic `$text` `$match`, or
44
+ * an Atlas `$search`). Both MUST be the pipeline's FIRST stage, hence its
45
+ * position in front of the filter `$match`. Resolving it needs adapter state,
46
+ * so the caller passes it in and this module stays a pure translation.
42
47
  */
43
- function buildPrefix(query) {
48
+ function buildPrefix(query, searchStage) {
44
49
  const controls = query.controls || {};
45
50
  const groupBy = controls.$groupBy ?? [];
46
- const pipeline = [{ $match: buildMongoFilter(query.filter) }];
51
+ const pipeline = searchStage ? [searchStage, { $match: buildMongoFilter(query.filter) }] : [{ $match: buildMongoFilter(query.filter) }];
47
52
  const groupId = {};
48
53
  const groupKeys = [];
49
54
  for (const [index, field] of groupBy.entries()) {
@@ -59,17 +64,19 @@ function buildPrefix(query) {
59
64
  };
60
65
  }
61
66
  /**
62
- * The stages every grouped query shares: `$match(filter)` → `$group`
63
- * (dimensions + accumulators) → `$project` (flatten `_id`, keep aliases) →
64
- * `$match($having)`. The row pipeline appends sort/skip/limit, the count
65
- * pipeline appends `$count`, so both see exactly the same group set.
67
+ * The stages every grouped query shares: `[search →] $match(filter)` →
68
+ * `$group` (dimensions + accumulators) → `$project` (flatten `_id`, keep
69
+ * aliases) → `$match($having)`. The row pipeline appends sort/skip/limit, the
70
+ * count pipeline appends `$count`, so both see exactly the same group set —
71
+ * including the same `$search` narrowing, which is why the stage is threaded
72
+ * through this single seam instead of being appended by each caller.
66
73
  *
67
74
  * With `accumulators: false` only the `$group._id` dimensions are emitted
68
75
  * (no accumulators, no `$project`, no `$having`) — the cheapest shape for a
69
76
  * plain group count, where no alias has to be resolvable.
70
77
  */
71
- function buildGroupedStages(query, { accumulators }) {
72
- const { pipeline, groupId, groupKeys, controls } = buildPrefix(query);
78
+ function buildGroupedStages(query, { accumulators, searchStage }) {
79
+ const { pipeline, groupId, groupKeys, controls } = buildPrefix(query, searchStage);
73
80
  const groupStage = { _id: groupId };
74
81
  if (!accumulators) {
75
82
  pipeline.push({ $group: groupStage });
@@ -96,10 +103,18 @@ function buildGroupedStages(query, { accumulators }) {
96
103
  /**
97
104
  * Builds a full MongoDB aggregation pipeline for GROUP BY queries.
98
105
  *
99
- * Pipeline: $match → $group → $project → $match(having) → $sort → $skip → $limit
106
+ * Pipeline: [search →] $match → $group → $project → $match(having) → $sort →
107
+ * $skip → $limit
108
+ *
109
+ * `searchStage` is the resolved `$search` / `$text` stage (see
110
+ * `buildAggregateSearchStage`); unlike the leaf runner this path adds no
111
+ * relevance `$sort` and no default `$limit`.
100
112
  */
101
- function buildAggregatePipeline(query) {
102
- const { pipeline, controls } = buildGroupedStages(query, { accumulators: true });
113
+ function buildAggregatePipeline(query, searchStage) {
114
+ const { pipeline, controls } = buildGroupedStages(query, {
115
+ accumulators: true,
116
+ searchStage
117
+ });
103
118
  if (controls.$sort) pipeline.push({ $sort: controls.$sort });
104
119
  if (controls.$skip) pipeline.push({ $skip: controls.$skip });
105
120
  if (controls.$limit) pipeline.push({ $limit: controls.$limit });
@@ -112,10 +127,16 @@ function buildAggregatePipeline(query) {
112
127
  * `$having` has a value to match; without it only the `$group._id`
113
128
  * dimensions are needed.
114
129
  *
115
- * Pipeline: $match → $group → [$project → $match(having)] → $count
130
+ * Pipeline: [search →] $match → $group → [$project → $match(having)] → $count
131
+ *
132
+ * `searchStage` must be the SAME stage handed to `buildAggregatePipeline` for
133
+ * the same query — the counted groups are exactly the rows the search matched.
116
134
  */
117
- function buildCountPipeline(query) {
118
- const { pipeline } = buildGroupedStages(query, { accumulators: Boolean(query.controls?.$having) });
135
+ function buildCountPipeline(query, searchStage) {
136
+ const { pipeline } = buildGroupedStages(query, {
137
+ accumulators: Boolean(query.controls?.$having),
138
+ searchStage
139
+ });
119
140
  pipeline.push({ $count: "count" });
120
141
  return pipeline;
121
142
  }
package/dist/index.cjs CHANGED
@@ -2,6 +2,7 @@ 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
+ let _atscript_db_agg = require("@atscript/db/agg");
5
6
  //#region src/lib/path-utils.ts
6
7
  function joinPath(prefix, segment) {
7
8
  return prefix ? `${prefix}.${segment}` : segment;
@@ -648,16 +649,39 @@ function isVectorSearchableImpl(host) {
648
649
  for (const index of host.getMongoSearchIndexes().values()) if (index.type === "vector") return true;
649
650
  return false;
650
651
  }
652
+ /**
653
+ * Resolves the leading stage for a text search, or throws when no index
654
+ * answers to `indexName` (the default index when it is omitted).
655
+ */
656
+ function requireSearchStage(host, text, indexName, controls) {
657
+ const plan = buildSearchStage(host, text, indexName, controls);
658
+ if (!plan) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
659
+ return plan;
660
+ }
661
+ /**
662
+ * Resolves the leading search stage for a GROUPED aggregate query, or
663
+ * `undefined` when the query carries no `$search` term (the plain grouped
664
+ * path). `$index` picks the search index by the same rules as `search()`, and
665
+ * an unresolvable one throws exactly as the leaf path does.
666
+ *
667
+ * `classicText` is deliberately dropped: it only drives the leaf runner's
668
+ * `_score` projection and relevance `$sort`, and no per-document score
669
+ * survives `$group`. See `resolveAggregateSearch` in `@atscript/db/agg` for
670
+ * the normative cross-adapter contract.
671
+ */
672
+ function buildAggregateSearchStage(host, controls) {
673
+ const search = (0, _atscript_db_agg.resolveAggregateSearch)(controls);
674
+ if (!search) return;
675
+ return requireSearchStage(host, search.text, search.indexName, controls).stage;
676
+ }
651
677
  /** Text search via $search aggregation stage. */
652
678
  async function searchImpl(host, text, query, indexName) {
653
- const plan = buildSearchStage(host, text, indexName, query.controls);
654
- if (!plan) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
679
+ const plan = requireSearchStage(host, text, indexName, query.controls);
655
680
  return runSearchPipeline(host, plan.stage, query, "search", void 0, plan.classicText);
656
681
  }
657
682
  /** Text search with faceted count. */
658
683
  async function searchWithCountImpl(host, text, query, indexName) {
659
- const plan = buildSearchStage(host, text, indexName, query.controls);
660
- if (!plan) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
684
+ const plan = requireSearchStage(host, text, indexName, query.controls);
661
685
  return runSearchWithCountPipeline(host, plan.stage, query, "searchWithCount", void 0, plan.classicText);
662
686
  }
663
687
  /** Vector search via $vectorSearch aggregation stage. */
@@ -1681,13 +1705,14 @@ var MongoAdapter = class MongoAdapter extends _atscript_db.BaseDbAdapter {
1681
1705
  }
1682
1706
  async aggregate(query) {
1683
1707
  const { buildAggregatePipeline, buildCountPipeline } = await Promise.resolve().then(() => require("./agg.cjs"));
1708
+ const searchStage = buildAggregateSearchStage(this, query.controls);
1684
1709
  if (query.controls?.$count) {
1685
- const pipeline = buildCountPipeline(query);
1710
+ const pipeline = buildCountPipeline(query, searchStage);
1686
1711
  this._log("aggregate (count)", pipeline);
1687
1712
  const result = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
1688
1713
  return result.length > 0 ? result : [{ count: 0 }];
1689
1714
  }
1690
- const pipeline = buildAggregatePipeline(query);
1715
+ const pipeline = buildAggregatePipeline(query, searchStage);
1691
1716
  this._log("aggregate", pipeline);
1692
1717
  return wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
1693
1718
  }
package/dist/index.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { t as buildMongoFilter } from "./mongo-filter-DceAGI-S.mjs";
2
2
  import { BaseDbAdapter, DbError, DbSpace, computeInsights, createFailureCollector, getDbFieldOp, getKeyProps, isAtscriptDbView, resolveDesignType } from "@atscript/db";
3
3
  import { MongoClient, MongoServerError, ObjectId } from "mongodb";
4
+ import { resolveAggregateSearch } from "@atscript/db/agg";
4
5
  //#region src/lib/path-utils.ts
5
6
  function joinPath(prefix, segment) {
6
7
  return prefix ? `${prefix}.${segment}` : segment;
@@ -647,16 +648,39 @@ function isVectorSearchableImpl(host) {
647
648
  for (const index of host.getMongoSearchIndexes().values()) if (index.type === "vector") return true;
648
649
  return false;
649
650
  }
651
+ /**
652
+ * Resolves the leading stage for a text search, or throws when no index
653
+ * answers to `indexName` (the default index when it is omitted).
654
+ */
655
+ function requireSearchStage(host, text, indexName, controls) {
656
+ const plan = buildSearchStage(host, text, indexName, controls);
657
+ if (!plan) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
658
+ return plan;
659
+ }
660
+ /**
661
+ * Resolves the leading search stage for a GROUPED aggregate query, or
662
+ * `undefined` when the query carries no `$search` term (the plain grouped
663
+ * path). `$index` picks the search index by the same rules as `search()`, and
664
+ * an unresolvable one throws exactly as the leaf path does.
665
+ *
666
+ * `classicText` is deliberately dropped: it only drives the leaf runner's
667
+ * `_score` projection and relevance `$sort`, and no per-document score
668
+ * survives `$group`. See `resolveAggregateSearch` in `@atscript/db/agg` for
669
+ * the normative cross-adapter contract.
670
+ */
671
+ function buildAggregateSearchStage(host, controls) {
672
+ const search = resolveAggregateSearch(controls);
673
+ if (!search) return;
674
+ return requireSearchStage(host, search.text, search.indexName, controls).stage;
675
+ }
650
676
  /** Text search via $search aggregation stage. */
651
677
  async function searchImpl(host, text, query, indexName) {
652
- const plan = buildSearchStage(host, text, indexName, query.controls);
653
- if (!plan) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
678
+ const plan = requireSearchStage(host, text, indexName, query.controls);
654
679
  return runSearchPipeline(host, plan.stage, query, "search", void 0, plan.classicText);
655
680
  }
656
681
  /** Text search with faceted count. */
657
682
  async function searchWithCountImpl(host, text, query, indexName) {
658
- const plan = buildSearchStage(host, text, indexName, query.controls);
659
- if (!plan) throw new Error(indexName ? `Search index "${indexName}" not found` : "No search index available");
683
+ const plan = requireSearchStage(host, text, indexName, query.controls);
660
684
  return runSearchWithCountPipeline(host, plan.stage, query, "searchWithCount", void 0, plan.classicText);
661
685
  }
662
686
  /** Vector search via $vectorSearch aggregation stage. */
@@ -1680,13 +1704,14 @@ var MongoAdapter = class MongoAdapter extends BaseDbAdapter {
1680
1704
  }
1681
1705
  async aggregate(query) {
1682
1706
  const { buildAggregatePipeline, buildCountPipeline } = await import("./agg.mjs");
1707
+ const searchStage = buildAggregateSearchStage(this, query.controls);
1683
1708
  if (query.controls?.$count) {
1684
- const pipeline = buildCountPipeline(query);
1709
+ const pipeline = buildCountPipeline(query, searchStage);
1685
1710
  this._log("aggregate (count)", pipeline);
1686
1711
  const result = await wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
1687
1712
  return result.length > 0 ? result : [{ count: 0 }];
1688
1713
  }
1689
- const pipeline = buildAggregatePipeline(query);
1714
+ const pipeline = buildAggregatePipeline(query, searchStage);
1690
1715
  this._log("aggregate", pipeline);
1691
1716
  return wrapInvalidQuery(() => this.aggregatePipeline(pipeline).toArray());
1692
1717
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/db-mongo",
3
- "version": "0.1.129",
3
+ "version": "0.1.131",
4
4
  "description": "Mongodb plugin for atscript.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -56,7 +56,7 @@
56
56
  "@atscript/core": "^0.1.92",
57
57
  "@atscript/typescript": "^0.1.92",
58
58
  "mongodb": "^6.17.0",
59
- "@atscript/db": "^0.1.129"
59
+ "@atscript/db": "^0.1.131"
60
60
  },
61
61
  "scripts": {
62
62
  "postinstall": "asc -f dts",