@simtlix/simfinity-js 3.4.1 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -55,8 +55,9 @@ const QLAggregationOperation = new GraphQLEnumType({
55
55
 
56
56
  #### New Functions
57
57
 
58
- ##### buildFieldPath(gqltype, fieldPath)
59
- - Resolves a field path string (e.g., "category" or "country.name") to MongoDB field paths
58
+ ##### buildFieldPath(gqltype, fieldPath) (historical, removed)
59
+ - Removed: groupId and fact paths are now resolved by the same validated resolver as filters, must end in a scalar field, and relation lookups use reserved `__sf_lN` aliases.
60
+ - Resolved a field path string (e.g., "category" or "country.name") to MongoDB field paths
60
61
  - Handles:
61
62
  - Direct scalar fields
62
63
  - Embedded object fields
package/README.md CHANGED
@@ -3,9 +3,11 @@
3
3
  MongoDB/Mongoose adapter and compatibility facade for Simfinity. This package generates GraphQL queries, mutations, relationships and models from `GraphQLObjectType` definitions using the shared Simfinity runtime.
4
4
 
5
5
  ```sh
6
- npm install @simtlix/simfinity-js graphql@^16.11.0 mongoose@^8.16.2
6
+ npm install @simtlix/simfinity-js graphql@^16.11.0 mongoose@^8.24.2
7
7
  ```
8
8
 
9
+ `graphql` and `mongoose` are peer dependencies: the facade uses your application's copies, and npm reports an out-of-range version as a peer conflict instead of installing a second copy. The MCP transports (`createMCPServer`, `startStdioMCPServer`, `createHTTPMCPHandler`) need the optional peer `@modelcontextprotocol/sdk@^1.13.0`; install it only when you use them. `generateMCPTools` works without it. The package does not install `graphql-middleware`.
10
+
9
11
  ```javascript
10
12
  import mongoose from 'mongoose';
11
13
  import * as simfinity from '@simtlix/simfinity-js';
@@ -18,6 +20,10 @@ const schema = simfinity.createSchema();
18
20
 
19
21
  Use a MongoDB replica set or sharded cluster for transactional mutations. The library requires Node.js >=18.18.0 and GraphQL 16.
20
22
 
23
+ Generated single-reference reads can be batched within a request. Models with any `find` or `findOne` pre/post query hook retain individual `findOne` reads, including hooks shared by both operations, to preserve access restrictions and other middleware behavior. See the [relationship contract](https://simtlix.github.io/simfinity.js/guide/relationships.html).
24
+
25
+ Mongoose 8.24.2 or later within version 8 is required to exclude the update-casting prototype-pollution vulnerability. Existing applications should update Mongoose and their dependency lockfile, then run `npm audit`; upgrading Simfinity does not refresh every transitive dependency already locked by the application.
26
+
21
27
  ## Optional transactional reference integrity
22
28
 
23
29
  Choose the mode once when creating an adapter; the default remains `'off'`:
@@ -40,6 +46,8 @@ Owned transactions use snapshot reads and majority commits. Supplied sessions ne
40
46
 
41
47
  The source now lives in `packages/mongodb` in the monorepo. Its public npm name remains `@simtlix/simfinity-js`; existing imports, including paths under `@simtlix/simfinity-js/src/`, retain their layout. Shared helpers, error identities and MCP compatibility exports are preserved. Internal Simfinity dependencies use the same exact release version.
42
48
 
49
+ For tooling that locates the package, use `require.resolve('@simtlix/simfinity-js/package.json')` or the package root without a trailing slash. Directory specifiers ending in `/` were not valid ESM imports and are not supported by the export map, including in `require.resolve`; use explicit filenames such as `src/auth/index.js`.
50
+
43
51
  - [MongoDB quick start](https://simtlix.github.io/simfinity.js/guide/getting-started.html)
44
52
  - [API reference](https://simtlix.github.io/simfinity.js/reference/api.html)
45
53
  - [Database comparison](https://simtlix.github.io/simfinity.js/guide/databases.html)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-js",
3
- "version": "3.4.1",
3
+ "version": "3.5.0",
4
4
  "description": "MongoDB/Mongoose adapter and compatibility facade for Simfinity GraphQL APIs",
5
5
  "main": "src/index.js",
6
6
  "types": "./types/index.d.ts",
@@ -16,21 +16,21 @@
16
16
  "directory": "packages/mongodb"
17
17
  },
18
18
  "peerDependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.13.0",
19
20
  "graphql": "^16.11.0",
20
- "mongoose": "^8.16.2"
21
+ "mongoose": "^8.24.2"
22
+ },
23
+ "peerDependenciesMeta": {
24
+ "@modelcontextprotocol/sdk": {
25
+ "optional": true
26
+ }
21
27
  },
22
28
  "config": {
23
29
  "owner": "simtlix"
24
30
  },
25
- "optionalDependencies": {
26
- "@modelcontextprotocol/sdk": "^1.13.0",
27
- "graphql": "^16.11.0",
28
- "graphql-middleware": "^6.1.35",
29
- "mongoose": "^8.16.2"
30
- },
31
31
  "dependencies": {
32
- "@simtlix/simfinity-core": "3.4.1",
33
- "@simtlix/simfinity-mcp": "3.4.1"
32
+ "@simtlix/simfinity-core": "3.5.0",
33
+ "@simtlix/simfinity-mcp": "3.5.0"
34
34
  },
35
35
  "files": [
36
36
  "src",
@@ -39,5 +39,25 @@
39
39
  "LICENSE",
40
40
  "AGGREGATION_EXAMPLE.md",
41
41
  "AGGREGATION_CHANGES_SUMMARY.md"
42
- ]
42
+ ],
43
+ "keywords": [
44
+ "simfinity",
45
+ "graphql",
46
+ "mongodb",
47
+ "mongoose",
48
+ "crud",
49
+ "nodejs"
50
+ ],
51
+ "exports": {
52
+ ".": {
53
+ "types": "./types/index.d.ts",
54
+ "default": "./src/index.js"
55
+ },
56
+ "./src": "./src/index.js",
57
+ "./src/auth": "./src/auth/index.js",
58
+ "./src/*.js": "./src/*.js",
59
+ "./src/*": "./src/*.js",
60
+ "./package": "./package.json",
61
+ "./*": "./*"
62
+ }
43
63
  }
@@ -10,6 +10,19 @@ mongoose.set('strictQuery', false);
10
10
 
11
11
  const withSession = (query, session) => (session ? query.session(session) : query);
12
12
 
13
+ // Even a shared hook can branch on `this.op` or the filter shape. Preserve findOne semantics
14
+ // whenever either query operation has hooks instead of inferring equivalence from function identity.
15
+ const hasNoFindHooks = (hooks) => {
16
+ if (!(hooks?._pres instanceof Map) || !(hooks._posts instanceof Map)) return false;
17
+ return ['find', 'findOne'].every((operation) => !hooks._pres.get(operation)?.length
18
+ && !hooks._posts.get(operation)?.length);
19
+ };
20
+
21
+ // getById reads through `findOne` and getByIds through `find`. Mongoose copies the schema's query
22
+ // hooks to the model when compiling it, so both registries must allow the batched read.
23
+ const batchesFindOne = (Model) => hasNoFindHooks(Model.schema?.s?.hooks)
24
+ && (Model.Query?.prototype?._queryMiddleware == null || hasNoFindHooks(Model.Query.prototype._queryMiddleware));
25
+
13
26
  export const createMongoAdapter = (options) => {
14
27
  const integrity = createMongoIntegrity(options);
15
28
  let queries;
@@ -82,6 +95,14 @@ export const createMongoAdapter = (options) => {
82
95
  if (plain) query = query.lean();
83
96
  return query;
84
97
  },
98
+ getByIds(Model, ids) {
99
+ integrity.assertReady();
100
+ // When `find` middleware could return other records, fail before reading, so the runtime
101
+ // reads each ID once with getById.
102
+ if (!batchesFindOne(Model)) throw new Error(`${Model.modelName} query middleware requires reads by ID`);
103
+ // Trusted, so that the `sanitizeFilter` option does not wrap the operator in `$eq`.
104
+ return Model.find({ _id: mongoose.trusted({ $in: ids }) });
105
+ },
85
106
  prepareUpdate(set, unset) {
86
107
  return Object.keys(unset).length > 0 ? { ...set, $unset: unset } : set;
87
108
  },
@@ -3,6 +3,9 @@ import { describeModels, SimfinityError } from '@simtlix/simfinity-core';
3
3
  import { withMongoTransaction } from './transactions.js';
4
4
 
5
5
  const LOCK_FIELD = '_simfinityReferenceLock';
6
+ // Startup audit bounds: distinct target ids checked per query and ids remembered per target.
7
+ const AUDIT_BATCH_SIZE = 1000;
8
+ const AUDIT_VERIFIED_LIMIT = 100000;
6
9
  const transactionOptions = { readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } };
7
10
  const violation = () => new SimfinityError('Reference constraint violated', 'REFERENCE_CONSTRAINT_VIOLATION', 409);
8
11
  const invalid = (message) => new SimfinityError(message, 'INVALID_MONGO_INTEGRITY_CONFIGURATION', 400);
@@ -165,21 +168,40 @@ export const createMongoIntegrity = (options = {}) => {
165
168
  await Model.init();
166
169
  }
167
170
  const session = await connection.startSession();
171
+ // Distinct target ids awaiting one batched existence query, and ids already
172
+ // found in this snapshot, per target model.
173
+ const pending = new Map();
174
+ const verified = new Map();
175
+ const verify = async (target) => {
176
+ const ids = pending.get(target);
177
+ pending.delete(target);
178
+ const found = new Set((await models.get(target).collection.find(
179
+ { _id: { $in: [...ids.values()] } }, { session, projection: { _id: 1 }, batchSize: ids.size + 1 },
180
+ ).toArray()).map(({ _id }) => _id.toHexString()));
181
+ for (const key of ids.keys()) if (!found.has(key)) throw violation();
182
+ if ((verified.get(target)?.size || 0) + ids.size > AUDIT_VERIFIED_LIMIT) verified.delete(target);
183
+ if (!verified.has(target)) verified.set(target, new Set());
184
+ for (const key of ids.keys()) verified.get(target).add(key);
185
+ };
168
186
  try {
169
187
  session.startTransaction({ readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } });
170
188
  for (const Model of models.values()) {
171
- const cursor = Model.collection.find({}, { session });
189
+ const projection = { _id: 1, [LOCK_FIELD]: 1 };
190
+ for (const { path } of outgoing.get(Model)) projection[path.join('.')] = 1;
191
+ const cursor = Model.collection.find({}, { session, projection });
172
192
  try {
173
193
  for await (const record of cursor) {
174
194
  if (record[LOCK_FIELD] != null && record[LOCK_FIELD]?._bsontype !== 'ObjectId') {
175
195
  throw invalid(`Existing ${LOCK_FIELD} data is incompatible with reference integrity`);
176
196
  }
177
197
  for (const { target, id } of modelReferences(Model, record)) {
178
- if (!await models.get(target).collection.findOne({ _id: id }, { session, projection: { _id: 1 } })) {
179
- throw violation();
180
- }
198
+ const key = id.toHexString();
199
+ if (verified.get(target)?.has(key)) continue;
200
+ if (!pending.has(target)) pending.set(target, new Map());
201
+ if (pending.get(target).set(key, id).size >= AUDIT_BATCH_SIZE) await verify(target);
181
202
  }
182
203
  }
204
+ for (const target of [...pending.keys()]) await verify(target);
183
205
  } finally { await cursor.close(); }
184
206
  }
185
207
  // Read-only startup audit. Mutation attempts use independently owned or supplied sessions.
@@ -24,32 +24,30 @@ const getListItemType = (type) => {
24
24
  return unwrapNonNull(listType.ofType);
25
25
  };
26
26
 
27
- const isCustomValidatedScalar = (type) => (
28
- type instanceof GraphQLScalarType && type.baseScalarType
29
- );
30
-
31
- const matchesScalar = (fieldType, target) => {
32
- if (fieldType === target) return true;
33
- if (fieldType instanceof GraphQLNonNull && fieldType.ofType === target) return true;
34
- if (isCustomValidatedScalar(fieldType) && fieldType.baseScalarType === target) return true;
35
- return fieldType instanceof GraphQLNonNull
36
- && isCustomValidatedScalar(fieldType.ofType)
37
- && fieldType.ofType.baseScalarType === target;
27
+ // Follow validated-scalar chains to their storage scalar; a cyclic chain stops where it repeats.
28
+ export const resolveStorageScalar = (type) => {
29
+ let base = type;
30
+ const visited = new Set();
31
+ while (base instanceof GraphQLScalarType && base.baseScalarType && !visited.has(base)) {
32
+ visited.add(base);
33
+ base = base.baseScalarType;
34
+ }
35
+ return base;
38
36
  };
39
37
 
38
+ const matchesScalar = (fieldType, target) => resolveStorageScalar(unwrapNonNull(fieldType)) === target;
39
+
40
40
  const getEffectiveTypeName = (type) => (
41
- type instanceof GraphQLScalarType && type.baseScalarType ? type.baseScalarType.name : type.name
41
+ type instanceof GraphQLScalarType ? resolveStorageScalar(type).name : type.name
42
42
  );
43
43
 
44
44
  const isGraphQLisoDate = (typeName) => (
45
45
  typeName === 'DateTime' || typeName === 'Date' || typeName === 'Time'
46
46
  );
47
47
 
48
- const listItemMatchesScalar = (listType, target) => {
49
- const ofType = getListItemType(listType);
50
- return ofType === target
51
- || (isCustomValidatedScalar(ofType) && ofType.baseScalarType === target);
52
- };
48
+ const listItemMatchesScalar = (listType, target) => (
49
+ resolveStorageScalar(getListItemType(listType)) === target
50
+ );
53
51
 
54
52
  const withUnique = (fieldEntry, mongoType) => (fieldEntry.extensions && fieldEntry.extensions.unique
55
53
  ? { type: mongoType, unique: true }
@@ -154,7 +152,10 @@ export const createMongoModel = (gqlType, onModelCreated, { createCollection = t
154
152
  onModelCreated(model);
155
153
  }
156
154
  if (createCollection) {
157
- model.createCollection();
155
+ // Mongoose already ignores NamespaceExists; report other failures instead of crashing the process.
156
+ Promise.resolve(model.createCollection()).catch((error) => {
157
+ console.warn(`Simfinity could not create MongoDB collection ${model.collection.collectionName}: ${error?.message ?? error}`);
158
+ });
158
159
  }
159
160
  return model;
160
161
  };
@@ -10,6 +10,8 @@ import mongoose from 'mongoose';
10
10
 
11
11
  import { SimfinityError, QLOperator, paginationStages } from '@simtlix/simfinity-core';
12
12
 
13
+ import { resolveStorageScalar } from './models.js';
14
+
13
15
  const isNonNullOfType = (fieldEntryType, graphQLType) => (
14
16
  fieldEntryType instanceof GraphQLNonNull && fieldEntryType.ofType instanceof graphQLType
15
17
  );
@@ -47,14 +49,39 @@ const getConnectionStorageName = (childType, declaredFieldName) => {
47
49
  };
48
50
 
49
51
  const getEffectiveTypeName = (type) => (
50
- type instanceof GraphQLScalarType && type.baseScalarType ? type.baseScalarType.name : type.name
52
+ type instanceof GraphQLScalarType ? resolveStorageScalar(type).name : type.name
51
53
  );
52
54
 
53
55
  const isGraphQLisoDate = (typeName) => (
54
56
  typeName === 'DateTime' || typeName === 'Date' || typeName === 'Time'
55
57
  );
56
58
 
59
+ // GraphQL reserves `__` names, so these aliases never replace a stored field.
60
+ const LOOKUP_ALIAS_PREFIX = '__sf_l';
61
+
57
62
  export const createMongoQueries = ({ getModel, getRegistrations }) => {
63
+ // One alias per relation path for each query build, keyed by the build's `included` accumulator.
64
+ const lookupAliases = new WeakMap();
65
+
66
+ const allocateLookupAlias = (aggregationsIncluded, relationPath) => {
67
+ let aliases = lookupAliases.get(aggregationsIncluded);
68
+ if (!aliases) {
69
+ aliases = new Map();
70
+ lookupAliases.set(aggregationsIncluded, aliases);
71
+ }
72
+ if (!aliases.has(relationPath)) aliases.set(relationPath, `${LOOKUP_ALIAS_PREFIX}${aliases.size}`);
73
+ return aliases.get(relationPath);
74
+ };
75
+
76
+ const appendLookups = (aggregateClauses, aggregationsIncluded, lookups) => {
77
+ for (const [alias, { lookup, unwind }] of Object.entries(lookups)) {
78
+ if (!Object.hasOwn(aggregationsIncluded, alias)) {
79
+ aggregateClauses.push(lookup, unwind);
80
+ aggregationsIncluded[alias] = true;
81
+ }
82
+ }
83
+ };
84
+
58
85
  const buildRelationLookup = ({
59
86
  collectionName, localField, foreignField, alias,
60
87
  }) => ({
@@ -127,7 +154,7 @@ const queryNamedType = (type) => {
127
154
  };
128
155
 
129
156
  // Resolve GraphQL paths and storage paths together; supplied models own ID casting.
130
- const resolveQueryPath = (gqltype, path) => {
157
+ const resolveQueryPath = (gqltype, path, aggregationsIncluded) => {
131
158
  assertValidFilterPath(path);
132
159
  const parts = path.split('.');
133
160
  let currentType = gqltype;
@@ -153,7 +180,7 @@ const resolveQueryPath = (gqltype, path) => {
153
180
  const declaredField = relation.connectionField || part;
154
181
  const connField = isList ? getConnectionStorageName(fieldType, declaredField) : declaredField;
155
182
  const localLeaf = isList ? '_id' : connField;
156
- const alias = mongoPath ? `${mongoPath.replaceAll('.', '_')}_${part}` : part;
183
+ const alias = allocateLookupAlias(aggregationsIncluded, parts.slice(0, index + 1).join('.'));
157
184
  aggregateClauses[alias] = buildRelationLookup({
158
185
  collectionName: relatedModel.collection.collectionName,
159
186
  localField: mongoPath ? `${mongoPath}.${localLeaf}` : localLeaf,
@@ -209,7 +236,7 @@ const castFilterValue = (value, resolved) => {
209
236
  const stored = resolved.storesStateName ? entry.name : entry.value;
210
237
  return schemaType ? schemaType.cast(stored) : stored;
211
238
  }
212
- const parsed = (scalar.baseScalarType || scalar).parseValue(value);
239
+ const parsed = resolveStorageScalar(scalar).parseValue(value);
213
240
  return schemaType ? schemaType.cast(parsed) : parsed;
214
241
  } catch {
215
242
  throw filterError(`Invalid value for ${resolved.mongoPath}`);
@@ -239,7 +266,7 @@ const buildMatchesClause = (resolved, operator, value) => {
239
266
  return { [resolved.mongoPath]: OP_TO_MONGO[op](coerced) };
240
267
  };
241
268
 
242
- const buildQueryTerms = async (filterField, qlField, fieldName, gqltype) => {
269
+ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype, aggregationsIncluded) => {
243
270
  const aggregateClauses = {};
244
271
  const matchesClauses = [];
245
272
  if (!qlField) throw filterError(`Unknown filter field: ${fieldName}`, 'INVALID_FILTER_FIELD');
@@ -253,7 +280,7 @@ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype) => {
253
280
  for (const term of terms) {
254
281
  if (!term || typeof term !== 'object' || Array.isArray(term)) throw filterError(`Invalid term for ${fieldName}`);
255
282
  if (isObject) assertValidFilterPath(term.path);
256
- const resolved = resolveQueryPath(gqltype, isObject ? `${fieldName}.${term.path}` : fieldName);
283
+ const resolved = resolveQueryPath(gqltype, isObject ? `${fieldName}.${term.path}` : fieldName, aggregationsIncluded);
257
284
  Object.assign(aggregateClauses, resolved.aggregateClauses);
258
285
  matchesClauses.push(buildMatchesClause(resolved, term.operator, term.value));
259
286
  }
@@ -303,15 +330,10 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
303
330
  filterInput = { operator: condition.operator, value: condition.value };
304
331
  }
305
332
 
306
- const result = await buildQueryTerms(filterInput, qlField, condition.field, gqltype);
333
+ const result = await buildQueryTerms(filterInput, qlField, condition.field, gqltype, aggregationsIncluded);
307
334
  if (!result) continue;
308
335
 
309
- for (const [prop, aggregate] of Object.entries(result.aggregateClauses)) {
310
- if (!aggregationsIncluded[prop]) {
311
- aggregateClauses.push(aggregate.lookup, aggregate.unwind);
312
- aggregationsIncluded[prop] = true;
313
- }
314
- }
336
+ appendLookups(aggregateClauses, aggregationsIncluded, result.aggregateClauses);
315
337
  for (const matchClause of Object.values(result.matchesClauses)) {
316
338
  for (const [matchKey, match] of Object.entries(matchClause)) {
317
339
  parts.push({ [matchKey]: match });
@@ -357,15 +379,10 @@ const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggreg
357
379
  for (const [key, filterField] of Object.entries(input)) {
358
380
  if (RESERVED_QUERY_KEYS.has(key)) continue;
359
381
  const qlField = fields[key];
360
- const result = await buildQueryTerms(filterField, qlField, key, gqltype);
382
+ const result = await buildQueryTerms(filterField, qlField, key, gqltype, aggregationsIncluded);
361
383
  if (!result) continue;
362
384
 
363
- for (const [prop, aggregate] of Object.entries(result.aggregateClauses)) {
364
- if (!aggregationsIncluded[prop]) {
365
- aggregateClauses.push(aggregate.lookup, aggregate.unwind);
366
- aggregationsIncluded[prop] = true;
367
- }
368
- }
385
+ appendLookups(aggregateClauses, aggregationsIncluded, result.aggregateClauses);
369
386
  for (const matchClause of Object.values(result.matchesClauses)) {
370
387
  for (const [matchKey, match] of Object.entries(matchClause)) {
371
388
  if (Object.hasOwn(flatMatchConditions, matchKey)) {
@@ -419,13 +436,8 @@ const validateSortTerms = (sort) => {
419
436
  const buildSortClause = (sortTerms, gqltype, aggregateClauses, aggregationsIncluded) => {
420
437
  const sortExpressions = {};
421
438
  for (const sort of sortTerms) {
422
- const resolved = resolveQueryPath(gqltype, sort.field);
423
- for (const [alias, aggregate] of Object.entries(resolved.aggregateClauses)) {
424
- if (!aggregationsIncluded[alias]) {
425
- aggregateClauses.push(aggregate.lookup, aggregate.unwind);
426
- aggregationsIncluded[alias] = true;
427
- }
428
- }
439
+ const resolved = resolveQueryPath(gqltype, sort.field, aggregationsIncluded);
440
+ appendLookups(aggregateClauses, aggregationsIncluded, resolved.aggregateClauses);
429
441
  sortExpressions[resolved.mongoPath] = sort.order === 'ASC' ? 1 : -1;
430
442
  }
431
443
  return { $sort: sortExpressions };
@@ -450,67 +462,14 @@ const buildQuery = async (input, gqltype, isCount) => {
450
462
 
451
463
  aggregateClauses.push(...paging);
452
464
 
453
- return aggregateClauses;
454
- };
455
-
456
- const buildFieldPath = (gqltype, fieldPath) => {
457
- assertValidFilterPath(fieldPath);
458
- const pathParts = fieldPath.split('.');
459
- const lookupPairs = [];
460
- let currentPath = '';
461
- let currentGQLType = gqltype;
462
-
463
- for (const part of pathParts) {
464
- const field = currentGQLType.getFields()[part];
465
- if (!field) {
466
- throw new Error(`Field ${part} not found in type ${currentGQLType.name}`);
467
- }
468
-
469
- const fieldType = unwrapListAndNonNull(field.type);
470
- const relation = field.extensions?.relation;
471
-
472
- if (fieldType instanceof GraphQLObjectType && relation && !relation.embedded) {
473
- const { collectionName } = getModel(fieldType).collection;
474
- const lookupAlias = currentPath ? `${currentPath}_${part}` : part;
475
- const inverseList = isListType(field.type);
476
- const declaredField = relation.connectionField || part;
477
- const connField = inverseList
478
- ? getConnectionStorageName(fieldType, declaredField)
479
- : declaredField;
480
- const localField = inverseList
481
- ? (currentPath ? `${currentPath}._id` : '_id')
482
- : (currentPath ? `${currentPath}.${connField}` : connField);
483
-
484
- lookupPairs.push(buildRelationLookup({
485
- collectionName,
486
- localField,
487
- foreignField: inverseList ? connField : '_id',
488
- alias: lookupAlias,
489
- }));
490
-
491
- currentPath = lookupAlias;
492
- currentGQLType = fieldType;
493
- } else if (fieldType instanceof GraphQLObjectType && relation?.embedded) {
494
- currentPath = currentPath ? `${currentPath}.${part}` : part;
495
- currentGQLType = fieldType;
496
- } else {
497
- const leaf = part === 'id' ? '_id' : part;
498
- currentPath = currentPath ? `${currentPath}.${leaf}` : leaf;
499
- }
500
- }
465
+ // Joined documents only serve $match/$sort; returned rows keep their stored references.
466
+ const lookupAliasList = Object.keys(aggregationsIncluded);
467
+ if (lookupAliasList.length > 0) aggregateClauses.push({ $unset: lookupAliasList });
501
468
 
502
- return { mongoPath: currentPath, lookupPairs };
469
+ return aggregateClauses;
503
470
  };
504
471
 
505
- const appendLookupPairs = (aggregateClauses, aggregationsIncluded, lookupPairs) => {
506
- for (const { lookup, unwind } of lookupPairs) {
507
- const alias = lookup.$lookup.as;
508
- if (!aggregationsIncluded[alias]) {
509
- aggregateClauses.push(lookup, unwind);
510
- aggregationsIncluded[alias] = true;
511
- }
512
- }
513
- };
472
+ const RESERVED_FACT_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
514
473
 
515
474
  const buildAggregationQuery = async (input, gqltype, aggregationExpression) => {
516
475
  const aggregateClauses = [];
@@ -520,21 +479,25 @@ const buildAggregationQuery = async (input, gqltype, aggregationExpression) => {
520
479
  const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded);
521
480
  if (matchStage) aggregateClauses.push(matchStage);
522
481
 
523
- const { groupId, facts } = aggregationExpression;
524
- const groupIdPath = buildFieldPath(gqltype, groupId);
525
- appendLookupPairs(aggregateClauses, aggregationsIncluded, groupIdPath.lookupPairs);
482
+ const { groupId, facts } = aggregationExpression ?? {};
483
+ if (!groupId || !Array.isArray(facts) || facts.length === 0) throw filterError('Aggregation requires a groupId and facts');
484
+ const groupIdPath = resolveQueryPath(gqltype, groupId, aggregationsIncluded);
485
+ appendLookups(aggregateClauses, aggregationsIncluded, groupIdPath.aggregateClauses);
526
486
 
527
487
  const groupStage = { $group: { _id: `$${groupIdPath.mongoPath}` } };
528
-
529
- facts.forEach((fact) => {
530
- const factPath = buildFieldPath(gqltype, fact.path);
531
- appendLookupPairs(aggregateClauses, aggregationsIncluded, factPath.lookupPairs);
532
-
533
- const builder = AGG_OP_TO_MONGO[fact.operation];
534
- if (!builder) {
535
- throw new Error(`Unknown aggregation operation: ${fact.operation}`);
488
+ const factNames = new Set();
489
+
490
+ // Positional accumulator names keep a fact named `_id` from replacing the group key.
491
+ facts.forEach((fact, index) => {
492
+ if (!Object.hasOwn(AGG_OP_TO_MONGO, fact?.operation)) throw filterError('Invalid aggregate operation');
493
+ if (typeof fact.factName !== 'string' || !PATH_SEGMENT_RE.test(fact.factName)
494
+ || RESERVED_FACT_NAMES.has(fact.factName) || factNames.has(fact.factName)) {
495
+ throw filterError('Invalid or duplicate aggregation fact name');
536
496
  }
537
- groupStage.$group[fact.factName] = builder(factPath.mongoPath);
497
+ factNames.add(fact.factName);
498
+ const factPath = resolveQueryPath(gqltype, fact.path, aggregationsIncluded);
499
+ appendLookups(aggregateClauses, aggregationsIncluded, factPath.aggregateClauses);
500
+ groupStage.$group[`fact_${index}`] = AGG_OP_TO_MONGO[fact.operation](factPath.mongoPath);
538
501
  });
539
502
 
540
503
  aggregateClauses.push(groupStage);
@@ -543,17 +506,16 @@ const buildAggregationQuery = async (input, gqltype, aggregationExpression) => {
543
506
  $project: {
544
507
  _id: 0,
545
508
  groupId: '$_id',
546
- facts: Object.fromEntries(facts.map((fact) => [fact.factName, `$${fact.factName}`])),
509
+ facts: Object.fromEntries(facts.map((fact, index) => [fact.factName, `$fact_${index}`])),
547
510
  },
548
511
  });
549
512
 
550
513
  const sortTerms = input.sort ? validateSortTerms(input.sort) : null;
551
514
  if (sortTerms) {
552
- const factNames = facts.map((fact) => fact.factName);
553
515
  const sortObject = {};
554
516
  sortTerms.forEach((sortTerm) => {
555
517
  const field = sortTerm.field || 'groupId';
556
- const sortFieldPath = factNames.includes(field) ? `facts.${field}` : 'groupId';
518
+ const sortFieldPath = factNames.has(field) ? `facts.${field}` : 'groupId';
557
519
  sortObject[sortFieldPath] = sortTerm.order === 'ASC' ? 1 : -1;
558
520
  });
559
521
  aggregateClauses.push({ $sort: sortObject });