@simtlix/simfinity-js 2.6.1 → 2.6.4

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.
Files changed (55) hide show
  1. package/README.md +23 -3
  2. package/package.json +1 -1
  3. package/src/index.js +155 -83
  4. package/.claude/worktrees/agitated-kepler/.claude/settings.local.json +0 -23
  5. package/.claude/worktrees/agitated-kepler/AGGREGATION_CHANGES_SUMMARY.md +0 -235
  6. package/.claude/worktrees/agitated-kepler/AGGREGATION_EXAMPLE.md +0 -567
  7. package/.claude/worktrees/agitated-kepler/LICENSE +0 -201
  8. package/.claude/worktrees/agitated-kepler/README.md +0 -3941
  9. package/.claude/worktrees/agitated-kepler/eslint.config.mjs +0 -71
  10. package/.claude/worktrees/agitated-kepler/package-lock.json +0 -4740
  11. package/.claude/worktrees/agitated-kepler/package.json +0 -41
  12. package/.claude/worktrees/agitated-kepler/src/auth/errors.js +0 -44
  13. package/.claude/worktrees/agitated-kepler/src/auth/expressions.js +0 -273
  14. package/.claude/worktrees/agitated-kepler/src/auth/index.js +0 -391
  15. package/.claude/worktrees/agitated-kepler/src/auth/rules.js +0 -274
  16. package/.claude/worktrees/agitated-kepler/src/const/QLOperator.js +0 -39
  17. package/.claude/worktrees/agitated-kepler/src/const/QLSort.js +0 -28
  18. package/.claude/worktrees/agitated-kepler/src/const/QLValue.js +0 -39
  19. package/.claude/worktrees/agitated-kepler/src/errors/internal-server.error.js +0 -11
  20. package/.claude/worktrees/agitated-kepler/src/errors/simfinity.error.js +0 -15
  21. package/.claude/worktrees/agitated-kepler/src/index.js +0 -2412
  22. package/.claude/worktrees/agitated-kepler/src/plugins.js +0 -53
  23. package/.claude/worktrees/agitated-kepler/src/scalars.js +0 -188
  24. package/.claude/worktrees/agitated-kepler/src/validators.js +0 -250
  25. package/.claude/worktrees/agitated-kepler/yarn.lock +0 -1154
  26. package/.claude/worktrees/distracted-swirles-4601fe/.claude/settings.local.json +0 -10
  27. package/.claude/worktrees/distracted-swirles-4601fe/AGGREGATION_CHANGES_SUMMARY.md +0 -235
  28. package/.claude/worktrees/distracted-swirles-4601fe/AGGREGATION_EXAMPLE.md +0 -567
  29. package/.claude/worktrees/distracted-swirles-4601fe/LICENSE +0 -201
  30. package/.claude/worktrees/distracted-swirles-4601fe/README.md +0 -3941
  31. package/.claude/worktrees/distracted-swirles-4601fe/eslint.config.mjs +0 -71
  32. package/.claude/worktrees/distracted-swirles-4601fe/package-lock.json +0 -4740
  33. package/.claude/worktrees/distracted-swirles-4601fe/package.json +0 -41
  34. package/.claude/worktrees/distracted-swirles-4601fe/src/auth/errors.js +0 -44
  35. package/.claude/worktrees/distracted-swirles-4601fe/src/auth/expressions.js +0 -273
  36. package/.claude/worktrees/distracted-swirles-4601fe/src/auth/index.js +0 -391
  37. package/.claude/worktrees/distracted-swirles-4601fe/src/auth/rules.js +0 -274
  38. package/.claude/worktrees/distracted-swirles-4601fe/src/const/QLOperator.js +0 -39
  39. package/.claude/worktrees/distracted-swirles-4601fe/src/const/QLSort.js +0 -28
  40. package/.claude/worktrees/distracted-swirles-4601fe/src/const/QLValue.js +0 -39
  41. package/.claude/worktrees/distracted-swirles-4601fe/src/errors/internal-server.error.js +0 -11
  42. package/.claude/worktrees/distracted-swirles-4601fe/src/errors/simfinity.error.js +0 -15
  43. package/.claude/worktrees/distracted-swirles-4601fe/src/index.js +0 -1925
  44. package/.claude/worktrees/distracted-swirles-4601fe/src/plugins.js +0 -53
  45. package/.claude/worktrees/distracted-swirles-4601fe/src/scalars.js +0 -188
  46. package/.claude/worktrees/distracted-swirles-4601fe/src/validators.js +0 -250
  47. package/.claude/worktrees/distracted-swirles-4601fe/yarn.lock +0 -1154
  48. package/.cursor/rules/simfinity-architecture.mdc +0 -71
  49. package/.cursor/rules/simfinity-auth-module.mdc +0 -100
  50. package/.cursor/rules/simfinity-coding-standards.mdc +0 -62
  51. package/.cursor/rules/simfinity-core-functions.mdc +0 -84
  52. package/.cursor/rules/simfinity-documentation.mdc +0 -59
  53. package/.cursor/rules/simfinity-extensions.mdc +0 -121
  54. package/.cursor/rules/simfinity-testing.mdc +0 -90
  55. package/eslint.config.mjs +0 -71
package/README.md CHANGED
@@ -352,6 +352,25 @@ query {
352
352
  }
353
353
  ```
354
354
 
355
+ As an equivalent shorthand, you can pass the full dotted path in `field` and omit `path`:
356
+
357
+ ```graphql
358
+ query {
359
+ books(
360
+ OR: [
361
+ { conditions: [{ field: "author.name", operator: LIKE, value: "Adams" }] }
362
+ { conditions: [{ field: "author.name", operator: LIKE, value: "Pratchett" }] }
363
+ ]
364
+ ) {
365
+ id
366
+ title
367
+ author { name }
368
+ }
369
+ }
370
+ ```
371
+
372
+ Both forms produce the same query. Multi-segment paths work too — e.g. `field: "author.country.code"` is equivalent to `field: "author", path: "country.code"`. Supplying both a dotted `field` and a separate `path` is rejected as ambiguous.
373
+
355
374
  #### Mixing Flat Filters with AND/OR
356
375
 
357
376
  You can freely combine the existing flat filter syntax with AND/OR groups. Flat filters and AND groups are all ANDed together at the top level:
@@ -439,7 +458,8 @@ query {
439
458
  AND: [
440
459
  { conditions: [{ field: "year", operator: GTE, value: 2022 }] }
441
460
  { conditions: [
442
- { field: "episodes", path: "name", operator: LIKE, value: "Final" }
461
+ # Equivalent to: { field: "episodes", path: "name", ... }
462
+ { field: "episodes.name", operator: LIKE, value: "Final" }
443
463
  ] }
444
464
  ]
445
465
  }
@@ -462,8 +482,8 @@ This translates to: `number > 1 AND (year = 2020 OR (year >= 2022 AND episodes.n
462
482
  | `QLFilterGroup` | `AND: [QLFilterGroup]`, `OR: [QLFilterGroup]`, `conditions: [QLFilterCondition]` | Recursive logical group |
463
483
  | `QLFilterCondition` | `field: String!`, `operator: QLOperator`, `value: QLValue`, `path: String` | Individual filter condition |
464
484
 
465
- - `field` identifies the entity field by name (e.g., `"title"`, `"author"`)
466
- - `path` is required for object/relationship fields (e.g., `"name"`, `"country.name"`)
485
+ - `field` identifies the entity field by name (e.g., `"title"`, `"author"`). For object/relationship fields you can also pass the full dotted path here as a shorthand (e.g., `"author.name"`, `"author.country.name"`)
486
+ - `path` is required for object/relationship fields when `field` is just the top-level name (e.g., `"name"`, `"country.name"`). Omit `path` if you used the dotted-`field` shorthand. Supplying both is an error.
467
487
  - Multiple `conditions` in the same group are combined with AND
468
488
  - Maximum nesting depth: 5 levels
469
489
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simtlix/simfinity-js",
3
- "version": "2.6.1",
3
+ "version": "2.6.4",
4
4
  "description": "",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/index.js CHANGED
@@ -28,6 +28,7 @@ const GraphQLJSON = new GraphQLScalarType({
28
28
  case Kind.BOOLEAN:
29
29
  return ast.value;
30
30
  case Kind.INT:
31
+ return parseInt(ast.value, 10);
31
32
  case Kind.FLOAT:
32
33
  return parseFloat(ast.value);
33
34
  case Kind.OBJECT: {
@@ -300,6 +301,40 @@ const buildRelationLookup = ({
300
301
  unwind: { $unwind: { path: `$${alias}`, preserveNullAndEmptyArrays: true } },
301
302
  });
302
303
 
304
+ const escapeRegex = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
305
+
306
+ const PATH_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
307
+
308
+ const assertValidFilterPath = (path) => {
309
+ if (typeof path !== 'string' || path.length === 0) {
310
+ throw new SimfinityError('Filter path must be a non-empty string', 'INVALID_FILTER_PATH', 400);
311
+ }
312
+ for (const segment of path.split('.')) {
313
+ if (!PATH_SEGMENT_RE.test(segment)) {
314
+ throw new SimfinityError(`Invalid filter path segment: "${segment}"`, 'INVALID_FILTER_PATH', 400);
315
+ }
316
+ }
317
+ };
318
+
319
+ const normalizeFilterCondition = (condition) => {
320
+ const { field } = condition;
321
+ if (typeof field !== 'string') return condition;
322
+ const dotIdx = field.indexOf('.');
323
+ if (dotIdx < 0) return condition;
324
+ if (condition.path != null && condition.path !== '') {
325
+ throw new SimfinityError(
326
+ 'Filter condition cannot use both dotted field syntax and a separate path',
327
+ 'AMBIGUOUS_FILTER_FIELD',
328
+ 400,
329
+ );
330
+ }
331
+ return {
332
+ ...condition,
333
+ field: field.slice(0, dotIdx),
334
+ path: field.slice(dotIdx + 1),
335
+ };
336
+ };
337
+
303
338
  const OP_TO_MONGO = {
304
339
  LT: (v) => ({ $lt: v }),
305
340
  GT: (v) => ({ $gt: v }),
@@ -307,7 +342,7 @@ const OP_TO_MONGO = {
307
342
  GTE: (v) => ({ $gte: v }),
308
343
  NE: (v) => ({ $ne: v }),
309
344
  BTW: (v) => ({ $gte: v[0], $lte: v[1] }),
310
- LIKE: (v) => ({ $regex: `.*${v}.*` }),
345
+ LIKE: (v) => ({ $regex: `.*${escapeRegex(v)}.*` }),
311
346
  };
312
347
 
313
348
  const AGG_OP_TO_MONGO = {
@@ -379,7 +414,13 @@ function createValidatedScalar(name, description, baseScalarType, validate) {
379
414
  const createTypeWithExcludedField = (inputNamePrefix, originalType, fieldToExclude) => {
380
415
  const originalFields = originalType.getFields();
381
416
  const newFields = Object.fromEntries(
382
- Object.entries(originalFields).filter(([fieldName]) => fieldName !== fieldToExclude),
417
+ Object.entries(originalFields)
418
+ .filter(([fieldName]) => fieldName !== fieldToExclude)
419
+ .map(([fieldName, field]) => [fieldName, {
420
+ type: field.type,
421
+ description: field.description,
422
+ defaultValue: field.defaultValue,
423
+ }]),
383
424
  );
384
425
 
385
426
  return new GraphQLInputObjectType({
@@ -446,9 +487,9 @@ const buildInputType = (gqltype) => {
446
487
 
447
488
  if (!fieldEntry.extensions || !fieldEntry.extensions.readOnly) {
448
489
  const hasStateMachine = !!typesDict.types[gqltype.name].stateMachine;
449
- const doesEstateFieldExistButIsManagedByStateMachine = !!(fieldEntryName === 'state' && hasStateMachine);
490
+ const stateFieldManagedByStateMachine = !!(fieldEntryName === 'state' && hasStateMachine);
450
491
 
451
- if (!doesEstateFieldExistButIsManagedByStateMachine) {
492
+ if (!stateFieldManagedByStateMachine) {
452
493
  if (fieldEntry.type instanceof GraphQLScalarType
453
494
  || fieldEntry.type instanceof GraphQLEnumType
454
495
  || isNonNullOfType(fieldEntry.type, GraphQLScalarType)
@@ -484,8 +525,8 @@ const buildInputType = (gqltype) => {
484
525
  if (fieldEntry.type.ofType === gqltype) {
485
526
  selfReferenceCollections[fieldEntryName] = fieldEntry;
486
527
  } else {
487
- const listInputTypeForAdd = graphQLListInputType(typesDict, fieldEntry, fieldEntryName, gqltype.name + 'A', fieldEntry.extensions?.relation?.connectionField);
488
- const listInputTypeForUpdate = graphQLListInputType(typesDictForUpdate, fieldEntry, fieldEntryName, gqltype.name +'U', fieldEntry.extensions?.relation?.connectionField);
528
+ const listInputTypeForAdd = graphQLListInputType(typesDict, fieldEntry, fieldEntryName, `${gqltype.name}A`, fieldEntry.extensions?.relation?.connectionField);
529
+ const listInputTypeForUpdate = graphQLListInputType(typesDictForUpdate, fieldEntry, fieldEntryName, `${gqltype.name}U`, fieldEntry.extensions?.relation?.connectionField);
489
530
  if (listInputTypeForAdd && listInputTypeForUpdate) {
490
531
  fieldArg.type = listInputTypeForAdd;
491
532
  fieldArgForUpdate.type = listInputTypeForUpdate;
@@ -557,29 +598,35 @@ const getInputType = (type) => typesDict.types[type.name].inputType;
557
598
  export { getInputType };
558
599
 
559
600
  const buildPendingInputTypes = (waitingForInputType) => {
560
- const stillWaitingInputType = {};
561
- let isThereAtLeastOneWaiting = false;
562
-
563
- Object.entries(waitingForInputType).forEach(([key, value]) => {
564
- const { gqltype } = value;
601
+ let pending = waitingForInputType;
602
+ let previousPendingCount = Object.keys(pending).length + 1;
603
+
604
+ while (Object.keys(pending).length > 0) {
605
+ const currentCount = Object.keys(pending).length;
606
+ if (currentCount >= previousPendingCount) {
607
+ const unresolved = Object.keys(pending).join(', ');
608
+ throw new SimfinityError(
609
+ `Could not build input types for: ${unresolved}. Check for circular or misconfigured relations.`,
610
+ 'INPUT_TYPE_UNRESOLVED',
611
+ 500,
612
+ );
613
+ }
614
+ previousPendingCount = currentCount;
565
615
 
566
- if (!typesDict.types[gqltype.name].inputType) {
567
- const buildInputTypeResult = buildInputType(gqltype);
616
+ const stillWaiting = {};
617
+ for (const [key, value] of Object.entries(pending)) {
618
+ const { gqltype } = value;
619
+ if (typesDict.types[gqltype.name].inputType) continue;
568
620
 
569
- if (buildInputTypeResult && buildInputTypeResult.inputTypeBody
570
- && buildInputTypeResult.inputTypeBodyForUpdate) {
571
- typesDict.types[gqltype.name].inputType = buildInputTypeResult.inputTypeBody;
572
- typesDictForUpdate.types[gqltype.name].inputType = buildInputTypeResult
573
- .inputTypeBodyForUpdate;
621
+ const result = buildInputType(gqltype);
622
+ if (result && result.inputTypeBody && result.inputTypeBodyForUpdate) {
623
+ typesDict.types[gqltype.name].inputType = result.inputTypeBody;
624
+ typesDictForUpdate.types[gqltype.name].inputType = result.inputTypeBodyForUpdate;
574
625
  } else {
575
- stillWaitingInputType[key] = value;
576
- isThereAtLeastOneWaiting = true;
626
+ stillWaiting[key] = value;
577
627
  }
578
628
  }
579
- });
580
-
581
- if (isThereAtLeastOneWaiting) {
582
- buildPendingInputTypes(stillWaitingInputType);
629
+ pending = stillWaiting;
583
630
  }
584
631
  };
585
632
 
@@ -622,9 +669,11 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
622
669
  null, operation, session)).modelArgs;
623
670
  }
624
671
  } else {
625
- modelArgs[fieldEntry.name] = new mongoose.Types
626
- .ObjectId(args[fieldEntryName].id);
627
- console.warn(`Configuration issue: Field ${fieldEntryName} does not define extensions.relation`);
672
+ throw new SimfinityError(
673
+ `Field ${gqltype.name}.${fieldEntryName} is an object type but does not define extensions.relation`,
674
+ 'MISSING_RELATION_EXTENSION',
675
+ 500,
676
+ );
628
677
  }
629
678
  } else if (fieldEntry.type instanceof GraphQLList) {
630
679
  const { ofType } = fieldEntry.type;
@@ -668,34 +717,38 @@ const materializeModel = async (args, gqltype, linkToParent, operation, session)
668
717
  const MAX_TRANSIENT_RETRIES = 5;
669
718
 
670
719
  const withTransaction = async (session, body) => {
720
+ const ownsSession = !session;
671
721
  const mySession = session || await mongoose.startSession();
672
- for (let attempt = 0; attempt <= MAX_TRANSIENT_RETRIES; attempt++) {
673
- await mySession.startTransaction();
674
- try {
675
- const result = await body(mySession);
676
- await mySession.commitTransaction();
677
- mySession.endSession();
678
- return result;
679
- } catch (error) {
680
- await mySession.abortTransaction();
681
- const isTransient = error.errorLabels?.includes('TransientTransactionError');
682
- if (isTransient && attempt < MAX_TRANSIENT_RETRIES) {
683
- continue;
722
+ try {
723
+ for (let attempt = 0; attempt <= MAX_TRANSIENT_RETRIES; attempt++) {
724
+ await mySession.startTransaction();
725
+ try {
726
+ const result = await body(mySession);
727
+ await mySession.commitTransaction();
728
+ return result;
729
+ } catch (error) {
730
+ await mySession.abortTransaction();
731
+ const isTransient = error.errorLabels?.includes('TransientTransactionError');
732
+ if (isTransient && attempt < MAX_TRANSIENT_RETRIES) {
733
+ continue;
734
+ }
735
+ throw error;
684
736
  }
737
+ }
738
+ throw new SimfinityError('Transaction exceeded retry limit', 'TRANSACTION_RETRY_EXCEEDED', 500);
739
+ } finally {
740
+ if (ownsSession) {
685
741
  mySession.endSession();
686
- throw error;
687
742
  }
688
743
  }
689
- mySession.endSession();
690
- throw new SimfinityError('Transaction exceeded retry limit', 'TRANSACTION_RETRY_EXCEEDED', 500);
691
744
  };
692
745
 
693
- const executeRegisteredMutation = (args, callback, session) => withTransaction(
746
+ const executeRegisteredMutation = (args, callback, context, session) => withTransaction(
694
747
  session,
695
- (mySession) => callback(args, mySession),
748
+ (mySession) => callback(args, mySession, context),
696
749
  );
697
750
 
698
- const iterateonCollectionFields = async (materializedModel, gqltype, objectId, session, context) => {
751
+ const iterateOnCollectionFields = async (materializedModel, gqltype, objectId, session, context) => {
699
752
  for (const [collectionFieldKey, collectionField] of Object.entries(materializedModel.collectionFields)) {
700
753
  if (collectionField.added) {
701
754
  await executeItemFunction(gqltype, collectionFieldKey, objectId, session,
@@ -741,7 +794,7 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
741
794
 
742
795
  if (embeddedFieldNames.length > 0) {
743
796
  const projection = Object.fromEntries(embeddedFieldNames.map((name) => [name, 1]));
744
- const currentObject = await Model.findById(objectId, projection).lean();
797
+ const currentObject = await Model.findById(objectId, projection).session(session).lean();
745
798
  if (currentObject) {
746
799
  for (const fieldEntryName of embeddedFieldNames) {
747
800
  const oldObjectData = currentObject[fieldEntryName];
@@ -775,7 +828,7 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
775
828
  ).session(session);
776
829
 
777
830
  if (materializedModel.collectionFields) {
778
- await iterateonCollectionFields(materializedModel, gqltype, objectId, session, context);
831
+ await iterateOnCollectionFields(materializedModel, gqltype, objectId, session, context);
779
832
  }
780
833
 
781
834
  if (controller && controller.onUpdated) {
@@ -786,7 +839,7 @@ const onUpdateSubject = async (Model, gqltype, controller, args, session, linkTo
786
839
  };
787
840
 
788
841
  const onStateChanged = async (Model, gqltype, controller, args, session, actionField, context) => {
789
- const storedModel = await Model.findById(args.id);
842
+ const storedModel = await Model.findById(args.id).session(session);
790
843
  if (!storedModel) {
791
844
  throw new SimfinityError(`${gqltype.name} ${args.id} is not valid`, 'NOT_VALID_ID', 404);
792
845
  }
@@ -822,10 +875,9 @@ const onSaveObject = async (Model, gqltype, controller, args, session, linkToPar
822
875
  result = result.toObject();
823
876
 
824
877
  if (materializedModel.collectionFields) {
825
- await iterateonCollectionFields(materializedModel, gqltype, newObject._id, session, context);
878
+ await iterateOnCollectionFields(materializedModel, gqltype, newObject._id, session, context);
826
879
  }
827
880
 
828
-
829
881
  if (controller && controller.onSaved) {
830
882
  await controller.onSaved(result, args, session, context);
831
883
  }
@@ -898,27 +950,25 @@ const executeItemFunction = async (gqltype, collectionField, objectId, session,
898
950
  const shouldNotBeIncludedInSchema = (includedTypes,
899
951
  type) => includedTypes && !includedTypes.includes(type);
900
952
 
901
- const excecuteMiddleware = (context) => {
953
+ const executeMiddleware = async (context) => {
902
954
  const buildNext = (middlewaresParam) => {
903
955
  if (!middlewaresParam) {
904
- return () => {};
956
+ return async () => {};
905
957
  }
906
- const next = () => {
958
+ return async () => {
907
959
  const middleware = middlewaresParam[0];
908
960
  if (middleware) {
909
- middleware(context, buildNext(middlewaresParam.slice(1)));
961
+ await middleware(context, buildNext(middlewaresParam.slice(1)));
910
962
  }
911
963
  };
912
- return next;
913
964
  };
914
965
 
915
- const middleware = buildNext(middlewares);
916
- middleware();
966
+ await buildNext(middlewares)();
917
967
  };
918
968
 
919
969
  const executeScope = async (params) => {
920
970
  const { type, args, operation, context } = params;
921
-
971
+
922
972
  if (!type || !type.gqltype || !type.gqltype.extensions) {
923
973
  return null;
924
974
  }
@@ -960,7 +1010,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
960
1010
  context,
961
1011
  };
962
1012
 
963
- excecuteMiddleware(params);
1013
+ await executeMiddleware(params);
964
1014
  return executeOperation(type.model, type.gqltype, type.controller,
965
1015
  args.input, operations.SAVE, null, null, context);
966
1016
  },
@@ -977,7 +1027,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
977
1027
  context,
978
1028
  };
979
1029
 
980
- excecuteMiddleware(params);
1030
+ await executeMiddleware(params);
981
1031
  return executeOperation(type.model, type.gqltype, type.controller,
982
1032
  args.id, operations.DELETE, null, null, context);
983
1033
  },
@@ -1002,7 +1052,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1002
1052
  context,
1003
1053
  };
1004
1054
 
1005
- excecuteMiddleware(params);
1055
+ await executeMiddleware(params);
1006
1056
  return executeOperation(type.model, type.gqltype, type.controller,
1007
1057
  args.input, operations.UPDATE, null, null, context);
1008
1058
  },
@@ -1024,7 +1074,7 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1024
1074
  context,
1025
1075
  };
1026
1076
 
1027
- excecuteMiddleware(params);
1077
+ await executeMiddleware(params);
1028
1078
  return executeOperation(type.model, type.gqltype, type.controller,
1029
1079
  args.input, operations.STATE_CHANGED, actionField, null, context);
1030
1080
  },
@@ -1051,8 +1101,8 @@ const buildMutation = (name, includedMutationTypes, includedCustomMutations) =>
1051
1101
  entry,
1052
1102
  context,
1053
1103
  };
1054
- excecuteMiddleware(params);
1055
- return executeRegisteredMutation(args.input, registeredMutation.callback);
1104
+ await executeMiddleware(params);
1105
+ return executeRegisteredMutation(args.input, registeredMutation.callback, context);
1056
1106
  },
1057
1107
  };
1058
1108
  }
@@ -1128,10 +1178,10 @@ const generateSchemaDefinition = (gqlType) => {
1128
1178
 
1129
1179
  const findObjectIdFields = (schemaDefinition, parentPath = '') => {
1130
1180
  const objectIdFields = [];
1131
-
1181
+
1132
1182
  for (const [fieldName, fieldDefinition] of Object.entries(schemaDefinition)) {
1133
1183
  const currentPath = parentPath ? `${parentPath}.${fieldName}` : fieldName;
1134
-
1184
+
1135
1185
  if (fieldDefinition === mongoose.Schema.Types.ObjectId) {
1136
1186
  objectIdFields.push(currentPath);
1137
1187
  } else if (typeof fieldDefinition === 'object' && fieldDefinition !== null) {
@@ -1147,7 +1197,7 @@ const findObjectIdFields = (schemaDefinition, parentPath = '') => {
1147
1197
  }
1148
1198
  }
1149
1199
  }
1150
-
1200
+
1151
1201
  return objectIdFields;
1152
1202
  };
1153
1203
 
@@ -1172,7 +1222,12 @@ const generateModel = (gqlType, onModelCreated, { createCollection = true } = {}
1172
1222
  return model;
1173
1223
  };
1174
1224
 
1175
- const coerceIdArray = (value) => value.map((element) => new mongoose.Types.ObjectId(element));
1225
+ const coerceIdArray = (value) => value.map((element) => {
1226
+ if (element === null || element === undefined) {
1227
+ throw new SimfinityError('ID value cannot be null in collection filter', 'INVALID_FILTER_VALUE', 400);
1228
+ }
1229
+ return new mongoose.Types.ObjectId(element);
1230
+ });
1176
1231
 
1177
1232
  const buildMatchesClause = (fieldname, operator, value) => {
1178
1233
  const isIdField = fieldname.endsWith('_id');
@@ -1181,11 +1236,12 @@ const buildMatchesClause = (fieldname, operator, value) => {
1181
1236
  if (op === 'EQ') {
1182
1237
  return { [fieldname]: isIdField ? new mongoose.Types.ObjectId(value) : value };
1183
1238
  }
1184
- if (op === 'IN') {
1185
- return { [fieldname]: { $in: value && isIdField ? coerceIdArray(value) : value } };
1186
- }
1187
- if (op === 'NIN') {
1188
- return { [fieldname]: { $nin: value && isIdField ? coerceIdArray(value) : value } };
1239
+ if (op === 'IN' || op === 'NIN') {
1240
+ if (!Array.isArray(value)) {
1241
+ throw new SimfinityError(`${op} requires an array value for ${fieldname}`, 'INVALID_FILTER_VALUE', 400);
1242
+ }
1243
+ const coerced = isIdField ? coerceIdArray(value) : value;
1244
+ return { [fieldname]: { [op === 'IN' ? '$in' : '$nin']: coerced } };
1189
1245
  }
1190
1246
  const builder = OP_TO_MONGO[op];
1191
1247
  return builder ? { [fieldname]: builder(value) } : {};
@@ -1223,9 +1279,14 @@ const buildAggregationsForSort = (filterField, qlField, fieldName) => {
1223
1279
  return aggregateClauses;
1224
1280
  }
1225
1281
 
1282
+ if (!Array.isArray(filterField?.terms) || filterField.terms.length === 0) {
1283
+ return aggregateClauses;
1284
+ }
1285
+
1226
1286
  const resolvedFieldType = unwrapNonNull(fieldType);
1227
1287
 
1228
1288
  filterField.terms.forEach((term) => {
1289
+ assertValidFilterPath(term.path);
1229
1290
  if (qlField.extensions?.relation && !qlField.extensions.relation.embedded
1230
1291
  && !aggregateClauses[fieldName]) {
1231
1292
  aggregateClauses[fieldName] = topLevelRelationLookup(qlField, resolvedFieldType, fieldName);
@@ -1267,6 +1328,10 @@ const buildQueryTerms = async (filterField, qlField, fieldName) => {
1267
1328
  const matchesClauses = {};
1268
1329
  const fieldType = unwrapListAndNonNull(qlField.type);
1269
1330
 
1331
+ if (filterField == null) {
1332
+ return { aggregateClauses, matchesClauses };
1333
+ }
1334
+
1270
1335
  if (fieldType instanceof GraphQLScalarType
1271
1336
  || isNonNullOfType(fieldType, GraphQLScalarType)
1272
1337
  || fieldType instanceof GraphQLEnumType
@@ -1280,9 +1345,14 @@ const buildQueryTerms = async (filterField, qlField, fieldName) => {
1280
1345
  return { aggregateClauses, matchesClauses };
1281
1346
  }
1282
1347
 
1348
+ if (!Array.isArray(filterField.terms) || filterField.terms.length === 0) {
1349
+ return { aggregateClauses, matchesClauses };
1350
+ }
1351
+
1283
1352
  const resolvedFieldType = unwrapNonNull(fieldType);
1284
1353
 
1285
1354
  filterField.terms.forEach((term) => {
1355
+ assertValidFilterPath(term.path);
1286
1356
  if (qlField.extensions?.relation && !qlField.extensions.relation.embedded
1287
1357
  && !aggregateClauses[fieldName]) {
1288
1358
  aggregateClauses[fieldName] = topLevelRelationLookup(qlField, resolvedFieldType, fieldName);
@@ -1346,7 +1416,8 @@ const buildFilterGroupMatch = async (filterGroup, gqltype, aggregateClauses, agg
1346
1416
  const fields = gqltype.getFields();
1347
1417
 
1348
1418
  if (filterGroup.conditions?.length > 0) {
1349
- for (const condition of filterGroup.conditions) {
1419
+ for (const rawCondition of filterGroup.conditions) {
1420
+ const condition = normalizeFilterCondition(rawCondition);
1350
1421
  const qlField = fields[condition.field];
1351
1422
  if (!qlField) {
1352
1423
  throw new SimfinityError(`Unknown filter field: ${condition.field}`, 'INVALID_FILTER_FIELD', 400);
@@ -1467,6 +1538,7 @@ const buildSortClause = (sortTerms, gqltype, aggregateClauses, aggregationsInclu
1467
1538
  const fields = gqltype.getFields();
1468
1539
 
1469
1540
  sortTerms.forEach((sort) => {
1541
+ assertValidFilterPath(sort.field);
1470
1542
  let fixedSortField = sort.field;
1471
1543
  if (sort.field.indexOf('.') >= 0) {
1472
1544
  const sortParts = sort.field.split('.');
@@ -1506,19 +1578,19 @@ const buildQuery = async (input, gqltype, isCount) => {
1506
1578
  aggregateClauses.push(buildSortClause(input.sort.terms, gqltype, aggregateClauses, aggregationsIncluded));
1507
1579
  }
1508
1580
 
1509
- let limitClause = { $limit: 100 };
1510
1581
  let skipClause = { $skip: 0 };
1582
+ let limitClause = { $limit: 100 };
1511
1583
  if (input.pagination?.page && input.pagination?.size) {
1512
- const skip = input.pagination.size * (input.pagination.page - 1);
1513
- limitClause = { $limit: input.pagination.size + skip };
1514
- skipClause = { $skip: skip };
1584
+ skipClause = { $skip: input.pagination.size * (input.pagination.page - 1) };
1585
+ limitClause = { $limit: input.pagination.size };
1515
1586
  }
1516
- aggregateClauses.push(limitClause, skipClause);
1587
+ aggregateClauses.push(skipClause, limitClause);
1517
1588
 
1518
1589
  return aggregateClauses;
1519
1590
  };
1520
1591
 
1521
1592
  const buildFieldPath = (gqltype, fieldPath) => {
1593
+ assertValidFilterPath(fieldPath);
1522
1594
  const pathParts = fieldPath.split('.');
1523
1595
  const lookupPairs = [];
1524
1596
  let currentPath = '';
@@ -1617,7 +1689,7 @@ const buildAggregationQuery = async (input, gqltype, aggregationExpression) => {
1617
1689
 
1618
1690
  if (input.pagination?.page && input.pagination?.size) {
1619
1691
  const skip = input.pagination.size * (input.pagination.page - 1);
1620
- aggregateClauses.push({ $limit: input.pagination.size + skip }, { $skip: skip });
1692
+ aggregateClauses.push({ $skip: skip }, { $limit: input.pagination.size });
1621
1693
  }
1622
1694
 
1623
1695
  return aggregateClauses;
@@ -1643,7 +1715,7 @@ const buildRootQuery = (name, includedTypes) => {
1643
1715
  const params = {
1644
1716
  type, args, operation: 'get_by_id', context,
1645
1717
  };
1646
- excecuteMiddleware(params);
1718
+ await executeMiddleware(params);
1647
1719
 
1648
1720
  const hasScope = type.gqltype.extensions?.scope?.get_by_id;
1649
1721
  if (!hasScope) {
@@ -1677,7 +1749,7 @@ const buildRootQuery = (name, includedTypes) => {
1677
1749
  operation: 'find',
1678
1750
  context,
1679
1751
  };
1680
- excecuteMiddleware(params);
1752
+ await executeMiddleware(params);
1681
1753
  await executeScope(params);
1682
1754
  const aggregateClauses = await buildQuery(args, type.gqltype);
1683
1755
  const wantsCount = !!(args.pagination && args.pagination.count);
@@ -1712,7 +1784,7 @@ const buildRootQuery = (name, includedTypes) => {
1712
1784
  operation: 'aggregate',
1713
1785
  context,
1714
1786
  };
1715
- excecuteMiddleware(params);
1787
+ await executeMiddleware(params);
1716
1788
  await executeScope(params);
1717
1789
  const aggregateClauses = await buildAggregationQuery(args, type.gqltype, args.aggregation);
1718
1790
  const result = await type.model.aggregate(aggregateClauses);
@@ -1,23 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(npm test:*)",
5
- "Bash(/usr/local/bin/node ./node_modules/.bin/vitest run)",
6
- "Bash(/opt/homebrew/bin/npx vitest:*)",
7
- "Bash(PATH=\"/opt/homebrew/bin:$PATH\" npm test)",
8
- "Bash(PATH=\"/opt/homebrew/bin:$PATH\" npm run lint)",
9
- "Bash(PATH=\"/opt/homebrew/bin:$PATH\" npx eslint tests/and-or-filters.test.js src/index.js)",
10
- "Bash(PATH=\"/opt/homebrew/bin:$PATH\" npx eslint tests/and-or-filters.test.js)",
11
- "Bash(PATH=\"/opt/homebrew/bin:$PATH\" git status)",
12
- "Bash(PATH=\"/opt/homebrew/bin:$PATH\" git diff --stat)",
13
- "Bash(PATH=\"/opt/homebrew/bin:$PATH\" git log --oneline -5)",
14
- "Bash(PATH=\"/opt/homebrew/bin:$PATH\" git diff README.md src/index.js)",
15
- "Bash(PATH=\"/opt/homebrew/bin:$PATH\" git add src/index.js tests/and-or-filters.test.js README.md)",
16
- "Bash(PATH=\"/opt/homebrew/bin:$PATH\" git checkout master)",
17
- "Bash(git merge:*)",
18
- "Bash(curl -s http://localhost:3000/graphql -H \"Content-Type: application/json\" -d '{\"\"\"\"query\"\"\"\":\"\"\"\"{ __schema { queryType { fields { name args { name type { name kind ofType { name } } } } } } }\"\"\"\"}')",
19
- "Bash(curl -s http://localhost:3000/graphql -H \"Content-Type: application/json\" -d '{\"\"\"\"query\"\"\"\":\"\"\"\"{ barbershops\\(pagination: { page: 1, size: 3 }\\) { id name state averageRating reviewCount } }\"\"\"\"}')",
20
- "Bash(PATH=\"/opt/homebrew/bin:$PATH\" node -p \"JSON.stringify\\(JSON.parse\\(require\\(''''fs''''\\).readFileSync\\(''''/dev/stdin'''',''''utf8''''\\)\\),null,2\\)\")"
21
- ]
22
- }
23
- }