@simtlix/simfinity-js 3.0.1 → 3.3.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.
@@ -0,0 +1,570 @@
1
+ import {
2
+ GraphQLEnumType,
3
+ GraphQLID,
4
+ GraphQLList,
5
+ GraphQLNonNull,
6
+ GraphQLObjectType,
7
+ GraphQLScalarType,
8
+ } from 'graphql';
9
+ import mongoose from 'mongoose';
10
+
11
+ import { SimfinityError, QLOperator, paginationStages } from '@simtlix/simfinity-core';
12
+
13
+ const isNonNullOfType = (fieldEntryType, graphQLType) => (
14
+ fieldEntryType instanceof GraphQLNonNull && fieldEntryType.ofType instanceof graphQLType
15
+ );
16
+
17
+ const unwrapNonNull = (type) => (type instanceof GraphQLNonNull ? type.ofType : type);
18
+
19
+ const unwrapListAndNonNull = (type) => {
20
+ let unwrapped = type;
21
+ while (unwrapped instanceof GraphQLList || unwrapped instanceof GraphQLNonNull) {
22
+ unwrapped = unwrapped.ofType;
23
+ }
24
+ return unwrapped;
25
+ };
26
+
27
+ const isListType = (type) => unwrapNonNull(type) instanceof GraphQLList;
28
+
29
+ const getFieldStorageName = (fieldName, field) => {
30
+ const relation = field.extensions?.relation;
31
+ return relation && !relation.embedded && !isListType(field.type)
32
+ ? relation.connectionField || fieldName
33
+ : fieldName;
34
+ };
35
+
36
+ const getConnectionStorageName = (childType, declaredFieldName) => {
37
+ const childFields = childType.getFields();
38
+ if (childFields[declaredFieldName]) {
39
+ return getFieldStorageName(declaredFieldName, childFields[declaredFieldName]);
40
+ }
41
+ const aliasedField = Object.entries(childFields).find(([fieldName, field]) => (
42
+ getFieldStorageName(fieldName, field) === declaredFieldName
43
+ ));
44
+ return aliasedField
45
+ ? getFieldStorageName(aliasedField[0], aliasedField[1])
46
+ : declaredFieldName;
47
+ };
48
+
49
+ const getEffectiveTypeName = (type) => (
50
+ type instanceof GraphQLScalarType && type.baseScalarType ? type.baseScalarType.name : type.name
51
+ );
52
+
53
+ const isGraphQLisoDate = (typeName) => (
54
+ typeName === 'DateTime' || typeName === 'Date' || typeName === 'Time'
55
+ );
56
+
57
+ export const createMongoQueries = ({ getModel, getRegistrations }) => {
58
+ const buildRelationLookup = ({
59
+ collectionName, localField, foreignField, alias,
60
+ }) => ({
61
+ lookup: {
62
+ $lookup: {
63
+ from: collectionName, foreignField, localField, as: alias,
64
+ },
65
+ },
66
+ unwind: { $unwind: { path: `$${alias}`, preserveNullAndEmptyArrays: true } },
67
+ });
68
+
69
+ const escapeRegex = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
70
+
71
+ const PATH_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
72
+
73
+ const assertValidFilterPath = (path) => {
74
+ if (typeof path !== 'string' || path.length === 0) {
75
+ throw new SimfinityError('Filter path must be a non-empty string', 'INVALID_FILTER_PATH', 400);
76
+ }
77
+ for (const segment of path.split('.')) {
78
+ if (!PATH_SEGMENT_RE.test(segment)) {
79
+ throw new SimfinityError(`Invalid filter path segment: "${segment}"`, 'INVALID_FILTER_PATH', 400);
80
+ }
81
+ }
82
+ };
83
+
84
+ const normalizeFilterCondition = (condition) => {
85
+ const { field } = condition;
86
+ if (typeof field !== 'string') return condition;
87
+ const dotIdx = field.indexOf('.');
88
+ if (dotIdx < 0) return condition;
89
+ if (condition.path != null && condition.path !== '') {
90
+ throw new SimfinityError(
91
+ 'Filter condition cannot use both dotted field syntax and a separate path',
92
+ 'AMBIGUOUS_FILTER_FIELD',
93
+ 400,
94
+ );
95
+ }
96
+ return {
97
+ ...condition,
98
+ field: field.slice(0, dotIdx),
99
+ path: field.slice(dotIdx + 1),
100
+ };
101
+ };
102
+
103
+ const OP_TO_MONGO = {
104
+ LT: (v) => ({ $lt: v }),
105
+ GT: (v) => ({ $gt: v }),
106
+ LTE: (v) => ({ $lte: v }),
107
+ GTE: (v) => ({ $gte: v }),
108
+ NE: (v) => ({ $ne: v }),
109
+ BTW: (v) => ({ $gte: v[0], $lte: v[1] }),
110
+ LIKE: (v) => ({ $regex: `.*${escapeRegex(v)}.*` }),
111
+ };
112
+
113
+ const AGG_OP_TO_MONGO = {
114
+ SUM: (path) => ({ $sum: `$${path}` }),
115
+ COUNT: () => ({ $sum: 1 }),
116
+ AVG: (path) => ({ $avg: `$${path}` }),
117
+ MIN: (path) => ({ $min: `$${path}` }),
118
+ MAX: (path) => ({ $max: `$${path}` }),
119
+ };
120
+
121
+ const filterError = (message, code = 'INVALID_FILTER_VALUE') => new SimfinityError(message, code, 400);
122
+
123
+ const queryNamedType = (type) => {
124
+ let result = type;
125
+ while (result instanceof GraphQLList || result instanceof GraphQLNonNull) result = result.ofType;
126
+ return result;
127
+ };
128
+
129
+ // Resolve GraphQL paths and storage paths together; supplied models own ID casting.
130
+ const resolveQueryPath = (gqltype, path) => {
131
+ assertValidFilterPath(path);
132
+ const parts = path.split('.');
133
+ let currentType = gqltype;
134
+ let currentModel = getModel(gqltype);
135
+ let mongoPath = '';
136
+ let schemaPath = '';
137
+ const aggregateClauses = {};
138
+
139
+ for (const [index, part] of parts.entries()) {
140
+ const field = Object.hasOwn(currentType.getFields(), part) ? currentType.getFields()[part] : null;
141
+ if (!field) throw filterError(`Unknown query field: ${path}`, 'INVALID_FILTER_FIELD');
142
+ const fieldType = queryNamedType(field.type);
143
+ const relation = field.extensions?.relation;
144
+ if (fieldType instanceof GraphQLObjectType) {
145
+ if (index === parts.length - 1) throw filterError(`Query path must end in a scalar field: ${path}`, 'INVALID_FILTER_PATH');
146
+ if (!relation || relation.embedded) {
147
+ mongoPath = mongoPath ? `${mongoPath}.${part}` : part;
148
+ schemaPath = schemaPath ? `${schemaPath}.${part}` : part;
149
+ } else {
150
+ const relatedModel = getModel(fieldType);
151
+ if (!relatedModel) throw filterError(`Related model is not available for ${path}`, 'INVALID_FILTER_PATH');
152
+ const isList = unwrapNonNull(field.type) instanceof GraphQLList;
153
+ const declaredField = relation.connectionField || part;
154
+ const connField = isList ? getConnectionStorageName(fieldType, declaredField) : declaredField;
155
+ const localLeaf = isList ? '_id' : connField;
156
+ const alias = mongoPath ? `${mongoPath.replaceAll('.', '_')}_${part}` : part;
157
+ aggregateClauses[alias] = buildRelationLookup({
158
+ collectionName: relatedModel.collection.collectionName,
159
+ localField: mongoPath ? `${mongoPath}.${localLeaf}` : localLeaf,
160
+ foreignField: isList ? connField : '_id',
161
+ alias,
162
+ });
163
+ mongoPath = alias;
164
+ schemaPath = '';
165
+ currentModel = relatedModel;
166
+ }
167
+ currentType = fieldType;
168
+ } else {
169
+ if (index !== parts.length - 1) throw filterError(`Cannot traverse scalar field in ${path}`, 'INVALID_FILTER_PATH');
170
+ const leaf = part === 'id' ? '_id' : part;
171
+ const storagePath = schemaPath ? `${schemaPath}.${leaf}` : leaf;
172
+ return {
173
+ mongoPath: mongoPath ? `${mongoPath}.${leaf}` : leaf,
174
+ schemaType: currentModel?.schema?.path(storagePath),
175
+ fieldType,
176
+ storesStateName: part === 'state' && !!getRegistrations().find((entry) => entry.gqltype.name === currentType.name)?.stateMachine,
177
+ aggregateClauses,
178
+ };
179
+ }
180
+ }
181
+ throw filterError(`Invalid query path: ${path}`, 'INVALID_FILTER_PATH');
182
+ };
183
+
184
+ const castFilterValue = (value, resolved) => {
185
+ if (value === null) return null;
186
+ if (value === undefined || Array.isArray(value)
187
+ || (typeof value === 'object' && !(value instanceof Date) && !(value instanceof mongoose.Types.ObjectId))) {
188
+ throw filterError(`Expected a scalar value for ${resolved.mongoPath}`);
189
+ }
190
+ try {
191
+ const schemaType = resolved.schemaType?.caster || resolved.schemaType;
192
+ const scalar = resolved.fieldType;
193
+ if (scalar === GraphQLID && typeof value !== 'string' && typeof value !== 'number'
194
+ && !(value instanceof mongoose.Types.ObjectId)) throw new Error('Invalid ID');
195
+ // ID representation is a persistence concern, including String/Number/custom _id schemas.
196
+ if (schemaType && (scalar === GraphQLID || resolved.mongoPath.endsWith('._id') || resolved.mongoPath === '_id'
197
+ || schemaType.instance === 'ObjectId')) return schemaType.cast(value);
198
+ const typeName = getEffectiveTypeName(scalar);
199
+ if (isGraphQLisoDate(typeName)) {
200
+ const date = value instanceof Date ? value : new Date(value);
201
+ if (Number.isNaN(date.getTime())) throw new Error('Invalid date');
202
+ return date;
203
+ }
204
+ if (scalar === GraphQLID && !schemaType) return new mongoose.Types.ObjectId(value);
205
+ if (scalar instanceof GraphQLEnumType) {
206
+ const entry = scalar.getValues().find((item) => item.name === value)
207
+ || scalar.getValues().find((item) => item.value === value);
208
+ if (!entry) throw new Error('Invalid enum value');
209
+ const stored = resolved.storesStateName ? entry.name : entry.value;
210
+ return schemaType ? schemaType.cast(stored) : stored;
211
+ }
212
+ const parsed = (scalar.baseScalarType || scalar).parseValue(value);
213
+ return schemaType ? schemaType.cast(parsed) : parsed;
214
+ } catch {
215
+ throw filterError(`Invalid value for ${resolved.mongoPath}`);
216
+ }
217
+ };
218
+
219
+ const buildMatchesClause = (resolved, operator, value) => {
220
+ const op = operator ?? 'EQ';
221
+ if (!QLOperator.getValues().some((entry) => entry.value === op)) {
222
+ throw filterError(`Unsupported filter operator: ${op}`, 'INVALID_FILTER_OPERATOR');
223
+ }
224
+ const collectionOperator = op === 'IN' || op === 'NIN' || op === 'BTW';
225
+ if (collectionOperator && (!Array.isArray(value) || (op === 'BTW' && value.length !== 2))) {
226
+ throw filterError(`${op} requires ${op === 'BTW' ? 'exactly two values' : 'an array value'}`);
227
+ }
228
+ if (op === 'LIKE' && (typeof value !== 'string' || getEffectiveTypeName(resolved.fieldType) !== 'String')) {
229
+ throw filterError('LIKE requires a string field and string value');
230
+ }
231
+ if (value === null && op !== 'EQ' && op !== 'NE') throw filterError(`${op} does not accept null`);
232
+ const cast = (item) => {
233
+ if (item === null && collectionOperator) throw filterError(`${op} does not accept null elements`);
234
+ return castFilterValue(item, resolved);
235
+ };
236
+ const coerced = collectionOperator ? value.map(cast) : op === 'LIKE' ? value : cast(value);
237
+ if (op === 'EQ') return { [resolved.mongoPath]: coerced };
238
+ if (op === 'IN' || op === 'NIN') return { [resolved.mongoPath]: { [op === 'IN' ? '$in' : '$nin']: coerced } };
239
+ return { [resolved.mongoPath]: OP_TO_MONGO[op](coerced) };
240
+ };
241
+
242
+ const buildQueryTerms = async (filterField, qlField, fieldName, gqltype) => {
243
+ const aggregateClauses = {};
244
+ const matchesClauses = [];
245
+ if (!qlField) throw filterError(`Unknown filter field: ${fieldName}`, 'INVALID_FILTER_FIELD');
246
+ if (filterField == null) return { aggregateClauses, matchesClauses };
247
+ if (typeof filterField !== 'object' || Array.isArray(filterField)) throw filterError(`Invalid filter for ${fieldName}`);
248
+ const isObject = queryNamedType(qlField.type) instanceof GraphQLObjectType;
249
+ if (isObject && (!Array.isArray(filterField.terms) || filterField.terms.length === 0)) {
250
+ throw filterError(`Filter on ${fieldName} requires non-empty terms`, 'MISSING_FILTER_PATH');
251
+ }
252
+ const terms = isObject ? filterField.terms : [filterField];
253
+ for (const term of terms) {
254
+ if (!term || typeof term !== 'object' || Array.isArray(term)) throw filterError(`Invalid term for ${fieldName}`);
255
+ if (isObject) assertValidFilterPath(term.path);
256
+ const resolved = resolveQueryPath(gqltype, isObject ? `${fieldName}.${term.path}` : fieldName);
257
+ Object.assign(aggregateClauses, resolved.aggregateClauses);
258
+ matchesClauses.push(buildMatchesClause(resolved, term.operator, term.value));
259
+ }
260
+ return { aggregateClauses, matchesClauses };
261
+ };
262
+
263
+ const MAX_FILTER_GROUP_DEPTH = 5;
264
+
265
+ const validateLogicalLists = (group) => {
266
+ if (!group || typeof group !== 'object' || Array.isArray(group)) throw filterError('Expected a filter group object');
267
+ for (const key of ['AND', 'OR', 'conditions']) {
268
+ if (group[key] != null && !Array.isArray(group[key])) throw filterError(`${key} requires an array`);
269
+ }
270
+ };
271
+
272
+ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, aggregationsIncluded, depth = 0) => {
273
+ if (depth > MAX_FILTER_GROUP_DEPTH) {
274
+ throw new SimfinityError('Filter nesting too deep', 'FILTER_DEPTH_EXCEEDED', 400);
275
+ }
276
+ validateLogicalLists(filterGroup);
277
+
278
+ const parts = [];
279
+ const fields = gqltype.getFields();
280
+
281
+ if (filterGroup.conditions?.length > 0) {
282
+ for (const rawCondition of filterGroup.conditions) {
283
+ if (!rawCondition || typeof rawCondition.field !== 'string') throw filterError('Filter conditions require a field');
284
+ const condition = normalizeFilterCondition(rawCondition);
285
+ const qlField = fields[condition.field];
286
+ if (!qlField) {
287
+ throw new SimfinityError(`Unknown filter field: ${condition.field}`, 'INVALID_FILTER_FIELD', 400);
288
+ }
289
+
290
+ const fieldType = unwrapListAndNonNull(qlField.type);
291
+ const isObject = fieldType instanceof GraphQLObjectType || isNonNullOfType(fieldType, GraphQLObjectType);
292
+
293
+ let filterInput;
294
+ if (isObject) {
295
+ if (!condition.path) {
296
+ throw new SimfinityError(`Filter on object field "${condition.field}" requires a path`, 'MISSING_FILTER_PATH', 400);
297
+ }
298
+ filterInput = {
299
+ terms: [{ path: condition.path, operator: condition.operator, value: condition.value }],
300
+ };
301
+ } else {
302
+ if (condition.path != null && condition.path !== '') throw filterError(`Cannot traverse scalar field ${condition.field}`, 'INVALID_FILTER_PATH');
303
+ filterInput = { operator: condition.operator, value: condition.value };
304
+ }
305
+
306
+ const result = await buildQueryTerms(filterInput, qlField, condition.field, gqltype);
307
+ if (!result) continue;
308
+
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
+ }
315
+ for (const matchClause of Object.values(result.matchesClauses)) {
316
+ for (const [matchKey, match] of Object.entries(matchClause)) {
317
+ parts.push({ [matchKey]: match });
318
+ }
319
+ }
320
+ }
321
+ }
322
+
323
+ if (filterGroup.AND?.length > 0) {
324
+ for (const subGroup of filterGroup.AND) {
325
+ const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + 1);
326
+ if (subMatch) parts.push(subMatch);
327
+ }
328
+ }
329
+
330
+ if (filterGroup.OR?.length > 0) {
331
+ const orParts = [];
332
+ for (const subGroup of filterGroup.OR) {
333
+ const subMatch = await buildFilterGroupMatch(subGroup, gqltype, aggregateClauses, aggregationsIncluded, depth + 1);
334
+ if (subMatch) orParts.push(subMatch);
335
+ }
336
+ if (orParts.length === 1) {
337
+ parts.push(orParts[0]);
338
+ } else if (orParts.length > 1) {
339
+ parts.push({ $or: orParts });
340
+ }
341
+ }
342
+
343
+ if (parts.length === 0) return null;
344
+ if (parts.length === 1) return parts[0];
345
+ return { $and: parts };
346
+ };
347
+
348
+ const RESERVED_QUERY_KEYS = new Set(['pagination', 'sort', 'AND', 'OR', 'aggregation']);
349
+
350
+ const collectFiltersAndLookups = async (input, gqltype, aggregateClauses, aggregationsIncluded) => {
351
+ validateLogicalLists(input);
352
+ const flatMatchConditions = {};
353
+ const repeatedConditions = [];
354
+ let hasFlat = false;
355
+ const fields = gqltype.getFields();
356
+
357
+ for (const [key, filterField] of Object.entries(input)) {
358
+ if (RESERVED_QUERY_KEYS.has(key)) continue;
359
+ const qlField = fields[key];
360
+ const result = await buildQueryTerms(filterField, qlField, key, gqltype);
361
+ if (!result) continue;
362
+
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
+ }
369
+ for (const matchClause of Object.values(result.matchesClauses)) {
370
+ for (const [matchKey, match] of Object.entries(matchClause)) {
371
+ if (Object.hasOwn(flatMatchConditions, matchKey)) {
372
+ repeatedConditions.push({ [matchKey]: match });
373
+ } else {
374
+ flatMatchConditions[matchKey] = match;
375
+ }
376
+ hasFlat = true;
377
+ }
378
+ }
379
+ }
380
+
381
+ const topLevelAndParts = [];
382
+ if (hasFlat) topLevelAndParts.push(flatMatchConditions);
383
+ topLevelAndParts.push(...repeatedConditions);
384
+
385
+ if (input.AND?.length > 0) {
386
+ for (const group of input.AND) {
387
+ const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded);
388
+ if (groupMatch) topLevelAndParts.push(groupMatch);
389
+ }
390
+ }
391
+
392
+ if (input.OR?.length > 0) {
393
+ const orParts = [];
394
+ for (const group of input.OR) {
395
+ const groupMatch = await buildFilterGroupMatch(group, gqltype, aggregateClauses, aggregationsIncluded);
396
+ if (groupMatch) orParts.push(groupMatch);
397
+ }
398
+ if (orParts.length === 1) {
399
+ topLevelAndParts.push(orParts[0]);
400
+ } else if (orParts.length > 1) {
401
+ topLevelAndParts.push({ $or: orParts });
402
+ }
403
+ }
404
+
405
+ if (topLevelAndParts.length === 1) return { $match: topLevelAndParts[0] };
406
+ if (topLevelAndParts.length > 1) return { $match: { $and: topLevelAndParts } };
407
+ return null;
408
+ };
409
+
410
+ const validateSortTerms = (sort) => {
411
+ if (!Array.isArray(sort?.terms) || sort.terms.length === 0) throw filterError('Sort requires non-empty terms', 'INVALID_SORT');
412
+ for (const term of sort.terms) {
413
+ if (!term || !['ASC', 'DESC'].includes(term.order)) throw filterError('Sort order must be ASC or DESC', 'INVALID_SORT');
414
+ assertValidFilterPath(term.field);
415
+ }
416
+ return sort.terms;
417
+ };
418
+
419
+ const buildSortClause = (sortTerms, gqltype, aggregateClauses, aggregationsIncluded) => {
420
+ const sortExpressions = {};
421
+ 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
+ }
429
+ sortExpressions[resolved.mongoPath] = sort.order === 'ASC' ? 1 : -1;
430
+ }
431
+ return { $sort: sortExpressions };
432
+ };
433
+
434
+ const buildQuery = async (input, gqltype, isCount) => {
435
+ const aggregateClauses = [];
436
+ const aggregationsIncluded = {};
437
+ const paging = paginationStages(input.pagination, true);
438
+
439
+ const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded);
440
+ if (matchStage) aggregateClauses.push(matchStage);
441
+
442
+ if (isCount) {
443
+ aggregateClauses.push({ $count: 'size' });
444
+ return aggregateClauses;
445
+ }
446
+
447
+ if (input.sort) {
448
+ aggregateClauses.push(buildSortClause(validateSortTerms(input.sort), gqltype, aggregateClauses, aggregationsIncluded));
449
+ }
450
+
451
+ aggregateClauses.push(...paging);
452
+
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
+ }
501
+
502
+ return { mongoPath: currentPath, lookupPairs };
503
+ };
504
+
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
+ };
514
+
515
+ const buildAggregationQuery = async (input, gqltype, aggregationExpression) => {
516
+ const aggregateClauses = [];
517
+ const aggregationsIncluded = {};
518
+ const paging = paginationStages(input.pagination, false);
519
+
520
+ const matchStage = await collectFiltersAndLookups(input, gqltype, aggregateClauses, aggregationsIncluded);
521
+ if (matchStage) aggregateClauses.push(matchStage);
522
+
523
+ const { groupId, facts } = aggregationExpression;
524
+ const groupIdPath = buildFieldPath(gqltype, groupId);
525
+ appendLookupPairs(aggregateClauses, aggregationsIncluded, groupIdPath.lookupPairs);
526
+
527
+ 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}`);
536
+ }
537
+ groupStage.$group[fact.factName] = builder(factPath.mongoPath);
538
+ });
539
+
540
+ aggregateClauses.push(groupStage);
541
+
542
+ aggregateClauses.push({
543
+ $project: {
544
+ _id: 0,
545
+ groupId: '$_id',
546
+ facts: Object.fromEntries(facts.map((fact) => [fact.factName, `$${fact.factName}`])),
547
+ },
548
+ });
549
+
550
+ const sortTerms = input.sort ? validateSortTerms(input.sort) : null;
551
+ if (sortTerms) {
552
+ const factNames = facts.map((fact) => fact.factName);
553
+ const sortObject = {};
554
+ sortTerms.forEach((sortTerm) => {
555
+ const field = sortTerm.field || 'groupId';
556
+ const sortFieldPath = factNames.includes(field) ? `facts.${field}` : 'groupId';
557
+ sortObject[sortFieldPath] = sortTerm.order === 'ASC' ? 1 : -1;
558
+ });
559
+ aggregateClauses.push({ $sort: sortObject });
560
+ } else {
561
+ aggregateClauses.push({ $sort: { groupId: 1 } });
562
+ }
563
+
564
+ aggregateClauses.push(...paging);
565
+
566
+ return aggregateClauses;
567
+ };
568
+
569
+ return { buildQuery, buildFilterGroupMatch, buildAggregationQuery };
570
+ };
@@ -0,0 +1,81 @@
1
+ import mongoose from 'mongoose';
2
+ import { SimfinityError } from '@simtlix/simfinity-core';
3
+
4
+ const MAX_TRANSIENT_RETRIES = 5;
5
+ const MAX_COMMIT_RETRIES = 5;
6
+
7
+ const commitWithRetry = async (session) => {
8
+ for (let attempt = 0; ; attempt++) {
9
+ try {
10
+ await session.commitTransaction();
11
+ return;
12
+ } catch (error) {
13
+ const isUnknown = error?.errorLabels?.includes('UnknownTransactionCommitResult');
14
+ const isExpired = error?.code === 50 || error?.writeConcernError?.code === 50;
15
+ if (!isUnknown || isExpired || attempt >= MAX_COMMIT_RETRIES) {
16
+ throw error;
17
+ }
18
+ // An uncertain commit can already have succeeded. Retry only the commit,
19
+ // never the writes or hooks, even if this error also carries a transient label.
20
+ }
21
+ }
22
+ };
23
+
24
+ const endOwnedSession = async (session, failed) => {
25
+ try {
26
+ await session.endSession();
27
+ } catch (error) {
28
+ if (!failed) throw error;
29
+ }
30
+ };
31
+
32
+ export const withMongoTransaction = async (session, body, model) => {
33
+ const connection = model?.db || mongoose.connection;
34
+ if (session) {
35
+ if (!session.inTransaction()) {
36
+ throw new SimfinityError(
37
+ 'A supplied session must have an active transaction', 'ACTIVE_TRANSACTION_REQUIRED', 400,
38
+ );
39
+ }
40
+ // The caller owns retries, commit, abort, and cleanup for a borrowed session.
41
+ return body(session);
42
+ }
43
+
44
+ const mySession = connection === mongoose.connection
45
+ ? await mongoose.startSession() : await connection.startSession();
46
+ let failed = false;
47
+ try {
48
+ for (let attempt = 0; attempt <= MAX_TRANSIENT_RETRIES; attempt++) {
49
+ await mySession.startTransaction();
50
+ try {
51
+ const result = await body(mySession);
52
+ await commitWithRetry(mySession);
53
+ return result;
54
+ } catch (error) {
55
+ if (error?.errorLabels?.includes('UnknownTransactionCommitResult')) {
56
+ throw error;
57
+ }
58
+ if (mySession.inTransaction()) {
59
+ try {
60
+ await mySession.abortTransaction();
61
+ } catch {
62
+ // Do not replace the operation error or retry on a session whose
63
+ // previous transaction could not be aborted.
64
+ throw error;
65
+ }
66
+ }
67
+ const isTransient = error?.errorLabels?.includes('TransientTransactionError');
68
+ if (isTransient && attempt < MAX_TRANSIENT_RETRIES) {
69
+ continue;
70
+ }
71
+ throw error;
72
+ }
73
+ }
74
+ throw new SimfinityError('Transaction exceeded retry limit', 'TRANSACTION_RETRY_EXCEEDED', 500);
75
+ } catch (error) {
76
+ failed = true;
77
+ throw error;
78
+ } finally {
79
+ await endOwnedSession(mySession, failed);
80
+ }
81
+ };