@auto-engineer/server-generator-apollo-emmett 1.3.3 → 1.4.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.
Files changed (45) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +6 -6
  3. package/.turbo/turbo-type-check.log +1 -1
  4. package/CHANGELOG.md +29 -0
  5. package/dist/src/codegen/extract/commands.d.ts +3 -2
  6. package/dist/src/codegen/extract/commands.d.ts.map +1 -1
  7. package/dist/src/codegen/extract/commands.js.map +1 -1
  8. package/dist/src/codegen/extract/gwt.js.map +1 -1
  9. package/dist/src/codegen/extract/messages.js +1 -1
  10. package/dist/src/codegen/extract/messages.js.map +1 -1
  11. package/dist/src/codegen/extract/query.d.ts +2 -1
  12. package/dist/src/codegen/extract/query.d.ts.map +1 -1
  13. package/dist/src/codegen/extract/query.js +8 -4
  14. package/dist/src/codegen/extract/query.js.map +1 -1
  15. package/dist/src/codegen/extract/slice-normalizer.d.ts +2 -2
  16. package/dist/src/codegen/extract/slice-normalizer.d.ts.map +1 -1
  17. package/dist/src/codegen/extract/slice-normalizer.js.map +1 -1
  18. package/dist/src/codegen/extract/step-converter.d.ts +6 -6
  19. package/dist/src/codegen/extract/step-converter.d.ts.map +1 -1
  20. package/dist/src/codegen/extract/step-converter.js +20 -8
  21. package/dist/src/codegen/extract/step-converter.js.map +1 -1
  22. package/dist/src/codegen/extract/step-types.d.ts +16 -0
  23. package/dist/src/codegen/extract/step-types.d.ts.map +1 -1
  24. package/dist/src/codegen/extract/step-types.js +17 -0
  25. package/dist/src/codegen/extract/step-types.js.map +1 -1
  26. package/dist/src/codegen/templates/query/projection.specs.specs.ts +192 -0
  27. package/dist/src/codegen/templates/query/projection.specs.ts.ejs +51 -5
  28. package/dist/src/codegen/templates/query/projection.ts.ejs +1 -1
  29. package/dist/src/codegen/types.d.ts +1 -1
  30. package/dist/src/codegen/types.d.ts.map +1 -1
  31. package/dist/tsconfig.tsbuildinfo +1 -1
  32. package/package.json +4 -4
  33. package/src/codegen/extract/commands.ts +3 -2
  34. package/src/codegen/extract/gwt.ts +2 -2
  35. package/src/codegen/extract/messages.ts +1 -1
  36. package/src/codegen/extract/query.ts +10 -5
  37. package/src/codegen/extract/slice-normalizer.ts +2 -1
  38. package/src/codegen/extract/step-converter.specs.ts +168 -0
  39. package/src/codegen/extract/step-converter.ts +25 -9
  40. package/src/codegen/extract/step-types.specs.ts +134 -0
  41. package/src/codegen/extract/step-types.ts +28 -0
  42. package/src/codegen/templates/query/projection.specs.specs.ts +192 -0
  43. package/src/codegen/templates/query/projection.specs.ts.ejs +51 -5
  44. package/src/codegen/templates/query/projection.ts.ejs +1 -1
  45. package/src/codegen/types.ts +1 -1
@@ -1036,4 +1036,196 @@ describe('projection.specs.ts.ejs', () => {
1036
1036
  "
1037
1037
  `);
1038
1038
  });
1039
+
1040
+ it('should generate valid projection.ts when When clause is a query action (QueryActionRef)', async () => {
1041
+ const spec: SpecsSchema = {
1042
+ variant: 'specs',
1043
+ narratives: [
1044
+ {
1045
+ name: 'workout-flow',
1046
+ slices: [
1047
+ {
1048
+ type: 'command',
1049
+ name: 'log-workout',
1050
+ stream: 'workouts-${memberId}',
1051
+ client: { specs: [] },
1052
+ server: {
1053
+ description: '',
1054
+ specs: [
1055
+ {
1056
+ type: 'gherkin',
1057
+ feature: 'Log workout command',
1058
+ rules: [
1059
+ {
1060
+ name: 'Should record workouts',
1061
+ examples: [
1062
+ {
1063
+ name: 'User logs workout',
1064
+ steps: [
1065
+ {
1066
+ keyword: 'When',
1067
+ text: 'LogWorkout',
1068
+ docString: {
1069
+ memberId: 'mem_001',
1070
+ caloriesBurned: 250,
1071
+ },
1072
+ },
1073
+ {
1074
+ keyword: 'Then',
1075
+ text: 'WorkoutRecorded',
1076
+ docString: {
1077
+ memberId: 'mem_001',
1078
+ caloriesBurned: 250,
1079
+ },
1080
+ },
1081
+ ],
1082
+ },
1083
+ ],
1084
+ },
1085
+ ],
1086
+ },
1087
+ ],
1088
+ },
1089
+ },
1090
+ {
1091
+ type: 'query',
1092
+ name: 'view-workout-history',
1093
+ stream: 'workouts',
1094
+ request:
1095
+ 'query GetWorkoutHistory($memberId: ID!) { workoutHistory(memberId: $memberId) { totalCalories } }',
1096
+ client: { specs: [] },
1097
+ server: {
1098
+ description: '',
1099
+ data: {
1100
+ items: [
1101
+ {
1102
+ target: { type: 'State', name: 'WorkoutHistory' },
1103
+ origin: { type: 'projection', name: 'WorkoutHistoryProjection', idField: 'memberId' },
1104
+ },
1105
+ ],
1106
+ },
1107
+ specs: [
1108
+ {
1109
+ type: 'gherkin',
1110
+ feature: 'View workout history query',
1111
+ rules: [
1112
+ {
1113
+ name: 'Workout history projection',
1114
+ examples: [
1115
+ {
1116
+ name: 'Shows calories after workout recorded',
1117
+ steps: [
1118
+ {
1119
+ keyword: 'Given',
1120
+ text: 'WorkoutRecorded',
1121
+ docString: { memberId: 'mem_001', caloriesBurned: 250 },
1122
+ },
1123
+ { keyword: 'When', text: 'GetWorkoutHistory', docString: { memberId: 'mem_001' } },
1124
+ {
1125
+ keyword: 'Then',
1126
+ text: 'WorkoutHistory',
1127
+ docString: { memberId: 'mem_001', totalCalories: 250 },
1128
+ },
1129
+ ],
1130
+ },
1131
+ ],
1132
+ },
1133
+ ],
1134
+ },
1135
+ ],
1136
+ },
1137
+ },
1138
+ ],
1139
+ },
1140
+ ],
1141
+ messages: [
1142
+ {
1143
+ type: 'command',
1144
+ name: 'LogWorkout',
1145
+ fields: [
1146
+ { name: 'memberId', type: 'string', required: true },
1147
+ { name: 'caloriesBurned', type: 'number', required: true },
1148
+ ],
1149
+ },
1150
+ {
1151
+ type: 'event',
1152
+ name: 'WorkoutRecorded',
1153
+ source: 'internal',
1154
+ fields: [
1155
+ { name: 'memberId', type: 'string', required: true },
1156
+ { name: 'caloriesBurned', type: 'number', required: true },
1157
+ ],
1158
+ },
1159
+ { type: 'query', name: 'GetWorkoutHistory', fields: [{ name: 'memberId', type: 'string', required: true }] },
1160
+ {
1161
+ type: 'state',
1162
+ name: 'WorkoutHistory',
1163
+ fields: [
1164
+ { name: 'memberId', type: 'string', required: true },
1165
+ { name: 'totalCalories', type: 'number', required: true },
1166
+ ],
1167
+ },
1168
+ ],
1169
+ } as SpecsSchema;
1170
+
1171
+ const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
1172
+ const projectionFile = plans.find((p) => p.outputPath.endsWith('view-workout-history/projection.ts'));
1173
+
1174
+ expect(projectionFile?.contents).toMatchInlineSnapshot(`
1175
+ "import {
1176
+ inMemorySingleStreamProjection,
1177
+ type ReadEvent,
1178
+ type InMemoryReadEventMetadata,
1179
+ } from '@event-driven-io/emmett';
1180
+ import type { WorkoutHistory } from './state';
1181
+ import type { WorkoutRecorded } from '../log-workout/events';
1182
+
1183
+ type AllEvents = WorkoutRecorded;
1184
+
1185
+ export const projection = inMemorySingleStreamProjection<WorkoutHistory, AllEvents>({
1186
+ collectionName: 'WorkoutHistoryProjection',
1187
+ canHandle: ['WorkoutRecorded'],
1188
+ getDocumentId: (event) => event.data.memberId,
1189
+ evolve: (
1190
+ document: WorkoutHistory | null,
1191
+ event: ReadEvent<AllEvents, InMemoryReadEventMetadata>,
1192
+ ): WorkoutHistory | null => {
1193
+ switch (event.type) {
1194
+ case 'WorkoutRecorded': {
1195
+ /**
1196
+ * ## IMPLEMENTATION INSTRUCTIONS ##
1197
+ * Implement how this event updates the projection.
1198
+ *
1199
+ * **IMPORTANT - Internal State Pattern:**
1200
+ * If you need to track state beyond the public WorkoutHistory type (e.g., to calculate
1201
+ * aggregations, track previous values, etc.), follow this pattern:
1202
+ *
1203
+ * 1. Define an extended interface BEFORE the projection:
1204
+ * interface InternalWorkoutHistory extends WorkoutHistory {
1205
+ * internalField: SomeType;
1206
+ * }
1207
+ *
1208
+ * 2. Cast document parameter to extended type:
1209
+ * const current: InternalWorkoutHistory = document ?? { ...defaults };
1210
+ *
1211
+ * 3. Cast return values to extended type:
1212
+ * return { ...allFields, internalField } as InternalWorkoutHistory;
1213
+ *
1214
+ * This keeps internal state separate from the public GraphQL schema.
1215
+ */
1216
+ return {
1217
+ memberId: /* TODO: map from event.data */ '',
1218
+ totalCalories: /* TODO: map from event.data */ 0,
1219
+ };
1220
+ }
1221
+ default:
1222
+ return document;
1223
+ }
1224
+ },
1225
+ });
1226
+
1227
+ export default projection;
1228
+ "
1229
+ `);
1230
+ });
1039
1231
  });
@@ -5,6 +5,33 @@ const projName = projectionName || "UnknownProjection";
5
5
  const idField = projectionIdField ?? 'id';
6
6
  const uniqueEventTypes = allEventTypesArray;
7
7
 
8
+ // Extract query name from the slice's request field for query action detection
9
+ function extractQueryName(request) {
10
+ if (!request) return null;
11
+ // Match patterns like: query ViewWorkoutPlan($id: ID!) or query viewWorkoutPlan
12
+ const queryMatch = request.match(/query\s+(\w+)/i);
13
+ if (queryMatch) return queryMatch[1];
14
+ // For JSON AST format
15
+ if (request.startsWith('{') && request.includes('"kind"')) {
16
+ try {
17
+ const ast = JSON.parse(request);
18
+ if (ast?.definitions) {
19
+ const opDef = ast.definitions.find(d => d.kind === 'OperationDefinition');
20
+ if (opDef?.name?.value) return opDef.name.value;
21
+ }
22
+ } catch (e) {}
23
+ }
24
+ return null;
25
+ }
26
+
27
+ // Detect if a "When" step text is a query action (matches query name from request)
28
+ // If no query name can be extracted, returns false (treats as event - safe default)
29
+ function isQueryAction(whenText, queryName) {
30
+ if (!whenText || !queryName) return false;
31
+ return whenText === queryName || whenText.toLowerCase() === queryName.toLowerCase();
32
+ }
33
+
34
+ const queryName = extractQueryName(slice?.request);
8
35
  const ruleGroups = new Map();
9
36
 
10
37
  if (Array.isArray(slice?.server?.specs)) {
@@ -21,11 +48,19 @@ if (Array.isArray(slice?.server?.specs)) {
21
48
  const whenSteps = example.steps?.filter(step => step.keyword === 'When') || [];
22
49
  const thenSteps = example.steps?.filter(step => step.keyword === 'Then') || [];
23
50
 
51
+ // Check if the first "When" step is a query action
52
+ const firstWhenText = whenSteps[0]?.text || '';
53
+ const isQueryActionPattern = isQueryAction(firstWhenText, queryName);
54
+
24
55
  ruleGroups.get(ruleDescription).push({
25
56
  description: example.name || 'should handle events correctly',
26
57
  given: givenSteps.map(step => ({ eventRef: step.text, exampleData: step.docString || {} })),
27
- when: whenSteps.map(step => ({ eventRef: step.text, exampleData: step.docString || {} })),
28
- then: thenSteps.map(step => ({ stateRef: step.text, exampleData: step.docString || {} }))
58
+ // For query action pattern, use empty when array (query actions don't produce events)
59
+ when: isQueryActionPattern
60
+ ? { queryAction: firstWhenText, args: whenSteps[0]?.docString || {} }
61
+ : whenSteps.map(step => ({ eventRef: step.text, exampleData: step.docString || {} })),
62
+ then: thenSteps.map(step => ({ stateRef: step.text, exampleData: step.docString || {} })),
63
+ isQueryActionPattern
29
64
  });
30
65
  }
31
66
  }
@@ -39,11 +74,15 @@ if (Array.isArray(slice?.server?.specs)) {
39
74
  }
40
75
 
41
76
  for (const example of rule.examples || []) {
77
+ // Check if when is already a QueryActionRef
78
+ const isQueryActionPattern = example.when && !Array.isArray(example.when) && 'queryAction' in example.when;
79
+
42
80
  ruleGroups.get(ruleDescription).push({
43
81
  description: example.description || 'should handle events correctly',
44
82
  given: example.given || [],
45
83
  when: example.when || [],
46
- then: example.then || []
84
+ then: example.then || [],
85
+ isQueryActionPattern
47
86
  });
48
87
  }
49
88
  }
@@ -70,7 +109,11 @@ describe('<%= ruleDescription %>', () => {
70
109
 
71
110
  <% for (const testCase of ruleTests) {
72
111
  const givenEvents = Array.isArray(testCase.given) ? testCase.given : [];
73
- const whenEvents = Array.isArray(testCase.when) ? testCase.when : (testCase.when ? [testCase.when] : []);
112
+ // For query action pattern, whenEvents is empty since query actions don't produce events
113
+ const isQueryActionTest = testCase.isQueryActionPattern;
114
+ const whenEvents = isQueryActionTest
115
+ ? []
116
+ : (Array.isArray(testCase.when) ? testCase.when : (testCase.when ? [testCase.when] : []));
74
117
  const thenStates = Array.isArray(testCase.then) ? testCase.then : [];
75
118
  const allTestEvents = [...givenEvents, ...whenEvents].filter(e => e.eventRef && e.eventRef !== '');
76
119
 
@@ -78,7 +121,10 @@ describe('<%= ruleDescription %>', () => {
78
121
  const expectedState = thenStates.find(t => t.stateRef === targetName);
79
122
  if (!expectedState) continue;
80
123
 
81
- const description = testCase.description || 'should handle events correctly';
124
+ // For query action pattern, use a more descriptive test name
125
+ const description = testCase.description || (isQueryActionTest
126
+ ? 'should return correct state when queried'
127
+ : 'should handle events correctly');
82
128
  _%>
83
129
 
84
130
  it('<%= description %>', () =>
@@ -80,7 +80,7 @@ switch (event.type) {
80
80
  const queryGwt = slice.type === 'query'
81
81
  ? queryGwtMapping.find(gwt => {
82
82
  const inGiven = gwt.given && gwt.given.some(g => g.eventRef === event.type);
83
- const inWhen = gwt.when.some(g => g.eventRef === event.type);
83
+ const inWhen = Array.isArray(gwt.when) ? gwt.when.some(g => g.eventRef === event.type) : false;
84
84
  return inGiven || inWhen;
85
85
  })
86
86
  : undefined;
@@ -13,7 +13,7 @@ export interface Field {
13
13
  required: boolean;
14
14
  }
15
15
  export interface MessageDefinition {
16
- type: 'command' | 'event' | 'state';
16
+ type: 'command' | 'event' | 'state' | 'query';
17
17
  name: string;
18
18
  fields?: Array<{
19
19
  name: string;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/codegen/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAErF,YAAY,EAAE,UAAU,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC;AAEzD,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;IACnC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,CAAC;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,KAAK,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,CAAC,EAAE,OAAO,CAAC;QACnB,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,YAAY,CAAC,EAAE,OAAO,CAAC;KACxB,CAAC,CAAC;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,EAAE,QAAQ,EAAE,CAAC;IACnB,IAAI,EAAE,UAAU,GAAG,QAAQ,EAAE,CAAC;IAC9B,IAAI,EAAE,KAAK,CAAC,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,QAAQ,CAAC,CAAC;IACzD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../src/codegen/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAErF,YAAY,EAAE,UAAU,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC;AAEzD,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;IACnC,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,SAAS,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,KAAK,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,QAAQ,CAAC,EAAE,OAAO,CAAC;QACnB,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,YAAY,CAAC,EAAE,OAAO,CAAC;KACxB,CAAC,CAAC;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,EAAE,QAAQ,EAAE,CAAC;IACnB,IAAI,EAAE,UAAU,GAAG,QAAQ,EAAE,CAAC;IAC9B,IAAI,EAAE,KAAK,CAAC,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,QAAQ,CAAC,CAAC;IACzD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B"}