@auto-engineer/server-generator-apollo-emmett 1.45.3 → 1.46.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.
package/ketchup-plan.md CHANGED
@@ -1,7 +1,10 @@
1
- # Ketchup Plan: Skip react/register templates for react slices with empty examples
1
+ # Ketchup Plan: Referenced message type fields fail GraphQL schema generation
2
2
 
3
3
  ## TODO
4
4
 
5
5
  ## DONE
6
6
 
7
- - [x] Burst 1: Skip react.ts.ejs and register.ts.ejs when react slice has no examples
7
+ - [x] Burst 1: Add `resolveReferencedMessageTypes` helper + tests (a50d7d73)
8
+ - [x] Burst 2: Wire into `prepareTemplateData` + fix `graphqlType()` + fix `isEnumType` regression (93edcc4a)
9
+ - [x] Burst 3: Update query resolver template + test (e6a06086)
10
+ - [x] Burst 4: Update mutation resolver template + test (75e3ed80)
package/package.json CHANGED
@@ -32,8 +32,8 @@
32
32
  "uuid": "^11.0.0",
33
33
  "web-streams-polyfill": "^4.1.0",
34
34
  "zod": "^3.22.4",
35
- "@auto-engineer/narrative": "1.45.3",
36
- "@auto-engineer/message-bus": "1.45.3"
35
+ "@auto-engineer/narrative": "1.46.0",
36
+ "@auto-engineer/message-bus": "1.46.0"
37
37
  },
38
38
  "publishConfig": {
39
39
  "access": "public"
@@ -44,9 +44,9 @@
44
44
  "typescript": "^5.8.3",
45
45
  "vitest": "^3.2.4",
46
46
  "tsx": "^4.19.2",
47
- "@auto-engineer/cli": "1.45.3"
47
+ "@auto-engineer/cli": "1.46.0"
48
48
  },
49
- "version": "1.45.3",
49
+ "version": "1.46.0",
50
50
  "scripts": {
51
51
  "generate:server": "tsx src/cli/index.ts",
52
52
  "build": "tsc && tsx ../../scripts/fix-esm-imports.ts && rm -rf dist/src/codegen/templates && mkdir -p dist/src/codegen && cp -r src/codegen/templates dist/src/codegen/templates && cp src/server.ts dist/src && cp -r src/utils dist/src && cp -r src/domain dist/src",
@@ -0,0 +1,235 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveReferencedMessageTypes } from './scaffoldFromSchema';
3
+ import type { MessageDefinition } from './types';
4
+
5
+ function buildMessagesByName(messages: MessageDefinition[]): Map<string, MessageDefinition> {
6
+ return new Map(messages.map((m) => [m.name, m]));
7
+ }
8
+
9
+ describe('resolveReferencedMessageTypes', () => {
10
+ it('resolves a direct message type reference', () => {
11
+ const messages: MessageDefinition[] = [
12
+ {
13
+ type: 'state',
14
+ name: 'Ingredient',
15
+ fields: [
16
+ { name: 'name', type: 'string' },
17
+ { name: 'quantity', type: 'number' },
18
+ ],
19
+ },
20
+ ];
21
+
22
+ const result = resolveReferencedMessageTypes([{ name: 'item', type: 'Ingredient' }], buildMessagesByName(messages));
23
+
24
+ expect(result).toEqual([
25
+ {
26
+ name: 'Ingredient',
27
+ fields: [
28
+ { name: 'name', type: 'string' },
29
+ { name: 'quantity', type: 'number' },
30
+ ],
31
+ },
32
+ ]);
33
+ });
34
+
35
+ it('resolves Array<Type> references', () => {
36
+ const messages: MessageDefinition[] = [
37
+ {
38
+ type: 'state',
39
+ name: 'Ingredient',
40
+ fields: [
41
+ { name: 'name', type: 'string' },
42
+ { name: 'amount', type: 'number' },
43
+ ],
44
+ },
45
+ ];
46
+
47
+ const result = resolveReferencedMessageTypes(
48
+ [{ name: 'ingredients', type: 'Array<Ingredient>' }],
49
+ buildMessagesByName(messages),
50
+ );
51
+
52
+ expect(result).toEqual([
53
+ {
54
+ name: 'Ingredient',
55
+ fields: [
56
+ { name: 'name', type: 'string' },
57
+ { name: 'amount', type: 'number' },
58
+ ],
59
+ },
60
+ ]);
61
+ });
62
+
63
+ it('resolves Type[] references', () => {
64
+ const messages: MessageDefinition[] = [
65
+ {
66
+ type: 'state',
67
+ name: 'Tag',
68
+ fields: [{ name: 'label', type: 'string' }],
69
+ },
70
+ ];
71
+
72
+ const result = resolveReferencedMessageTypes([{ name: 'tags', type: 'Tag[]' }], buildMessagesByName(messages));
73
+
74
+ expect(result).toEqual([
75
+ {
76
+ name: 'Tag',
77
+ fields: [{ name: 'label', type: 'string' }],
78
+ },
79
+ ]);
80
+ });
81
+
82
+ it('resolves nested references in depth-first order', () => {
83
+ const messages: MessageDefinition[] = [
84
+ {
85
+ type: 'state',
86
+ name: 'Supplier',
87
+ fields: [{ name: 'companyName', type: 'string' }],
88
+ },
89
+ {
90
+ type: 'state',
91
+ name: 'Ingredient',
92
+ fields: [
93
+ { name: 'name', type: 'string' },
94
+ { name: 'supplier', type: 'Supplier' },
95
+ ],
96
+ },
97
+ ];
98
+
99
+ const result = resolveReferencedMessageTypes(
100
+ [{ name: 'ingredients', type: 'Array<Ingredient>' }],
101
+ buildMessagesByName(messages),
102
+ );
103
+
104
+ expect(result).toEqual([
105
+ {
106
+ name: 'Supplier',
107
+ fields: [{ name: 'companyName', type: 'string' }],
108
+ },
109
+ {
110
+ name: 'Ingredient',
111
+ fields: [
112
+ { name: 'name', type: 'string' },
113
+ { name: 'supplier', type: 'Supplier' },
114
+ ],
115
+ },
116
+ ]);
117
+ });
118
+
119
+ it('handles circular references without infinite loop', () => {
120
+ const messages: MessageDefinition[] = [
121
+ {
122
+ type: 'state',
123
+ name: 'Node',
124
+ fields: [
125
+ { name: 'value', type: 'string' },
126
+ { name: 'children', type: 'Array<Node>' },
127
+ ],
128
+ },
129
+ ];
130
+
131
+ const result = resolveReferencedMessageTypes([{ name: 'root', type: 'Node' }], buildMessagesByName(messages));
132
+
133
+ expect(result).toEqual([
134
+ {
135
+ name: 'Node',
136
+ fields: [
137
+ { name: 'value', type: 'string' },
138
+ { name: 'children', type: 'Array<Node>' },
139
+ ],
140
+ },
141
+ ]);
142
+ });
143
+
144
+ it('does not include primitives as referenced types', () => {
145
+ const messages: MessageDefinition[] = [
146
+ {
147
+ type: 'state',
148
+ name: 'Item',
149
+ fields: [{ name: 'label', type: 'string' }],
150
+ },
151
+ ];
152
+
153
+ const result = resolveReferencedMessageTypes(
154
+ [
155
+ { name: 'name', type: 'string' },
156
+ { name: 'count', type: 'number' },
157
+ { name: 'active', type: 'boolean' },
158
+ { name: 'created', type: 'Date' },
159
+ { name: 'tags', type: 'Array<string>' },
160
+ ],
161
+ buildMessagesByName(messages),
162
+ );
163
+
164
+ expect(result).toEqual([]);
165
+ });
166
+
167
+ it('deduplicates types referenced by multiple fields', () => {
168
+ const messages: MessageDefinition[] = [
169
+ {
170
+ type: 'state',
171
+ name: 'Ingredient',
172
+ fields: [{ name: 'name', type: 'string' }],
173
+ },
174
+ ];
175
+
176
+ const result = resolveReferencedMessageTypes(
177
+ [
178
+ { name: 'required', type: 'Array<Ingredient>' },
179
+ { name: 'optional', type: 'Array<Ingredient>' },
180
+ ],
181
+ buildMessagesByName(messages),
182
+ );
183
+
184
+ expect(result).toEqual([
185
+ {
186
+ name: 'Ingredient',
187
+ fields: [{ name: 'name', type: 'string' }],
188
+ },
189
+ ]);
190
+ });
191
+
192
+ it('handles nullable type references', () => {
193
+ const messages: MessageDefinition[] = [
194
+ {
195
+ type: 'state',
196
+ name: 'Address',
197
+ fields: [{ name: 'street', type: 'string' }],
198
+ },
199
+ ];
200
+
201
+ const result = resolveReferencedMessageTypes(
202
+ [{ name: 'address', type: 'Address | null' }],
203
+ buildMessagesByName(messages),
204
+ );
205
+
206
+ expect(result).toEqual([
207
+ {
208
+ name: 'Address',
209
+ fields: [{ name: 'street', type: 'string' }],
210
+ },
211
+ ]);
212
+ });
213
+
214
+ it('handles fields using tsType instead of type', () => {
215
+ const messages: MessageDefinition[] = [
216
+ {
217
+ type: 'state',
218
+ name: 'LineItem',
219
+ fields: [{ name: 'sku', type: 'string' }],
220
+ },
221
+ ];
222
+
223
+ const result = resolveReferencedMessageTypes(
224
+ [{ name: 'items', tsType: 'Array<LineItem>' }],
225
+ buildMessagesByName(messages),
226
+ );
227
+
228
+ expect(result).toEqual([
229
+ {
230
+ name: 'LineItem',
231
+ fields: [{ name: 'sku', type: 'string' }],
232
+ },
233
+ ]);
234
+ });
235
+ });
@@ -281,6 +281,12 @@ async function renderTemplate(
281
281
  const isInlineObject = isInlineObjectHelper;
282
282
  const isInlineObjectArray = isInlineObjectArrayHelper;
283
283
 
284
+ const messageNames = new Set(
285
+ (Array.isArray(data.messages) ? data.messages : [])
286
+ .filter((m): m is MessageDefinition => m != null && typeof m === 'object' && 'name' in m)
287
+ .map((m) => m.name),
288
+ );
289
+
284
290
  const convertPrimitiveType = (base: string): string => {
285
291
  if (base === 'ID') return 'ID';
286
292
  if (base === 'Int') return 'Int';
@@ -314,6 +320,7 @@ async function renderTemplate(
314
320
  if (base === 'object') return 'GraphQLJSON';
315
321
  if (isInlineObject(base)) return 'GraphQLJSON';
316
322
  if (isStringLiteralUnion(base)) return resolveEnumOrString(base);
323
+ if (messageNames.has(base)) return base;
317
324
 
318
325
  return convertPrimitiveType(base);
319
326
  };
@@ -338,12 +345,44 @@ async function renderTemplate(
338
345
 
339
346
  const isNullable = (rawTs: string): boolean => /\|\s*null\b/.test(rawTs);
340
347
 
341
- const isEnumType = createIsEnumType(toTsFieldType);
348
+ const rawIsEnumType = createIsEnumType(toTsFieldType);
349
+ const isEnumType = (tsType: string): boolean => {
350
+ if (!rawIsEnumType(tsType)) return false;
351
+ const baseName = toTsFieldType(tsType)
352
+ .replace(/\s*\|\s*null\b/g, '')
353
+ .replace(/\[\]$/, '')
354
+ .trim();
355
+ return !messageNames.has(baseName);
356
+ };
342
357
  const fieldUsesDate = createFieldUsesDate(graphqlType);
343
358
  const fieldUsesJSON = createFieldUsesJSON(graphqlType);
344
359
  const fieldUsesFloat = createFieldUsesFloat(graphqlType);
345
360
  const collectEnumNames = createCollectEnumNames(isEnumType, toTsFieldType);
346
361
 
362
+ const isReferencedMessageType = (ts: string): boolean => {
363
+ const base = (ts ?? '')
364
+ .trim()
365
+ .replace(/\s*\|\s*null\b/g, '')
366
+ .trim();
367
+ return messageNames.has(base);
368
+ };
369
+ const isReferencedMessageTypeArray = (ts: string): boolean => {
370
+ const base = (ts ?? '')
371
+ .trim()
372
+ .replace(/\s*\|\s*null\b/g, '')
373
+ .trim();
374
+ const m = base.match(/^Array<(.+)>$/) ?? base.match(/^(.+)\[\]$/);
375
+ return m !== null && messageNames.has(m[1].trim());
376
+ };
377
+ const extractReferencedTypeName = (ts: string): string => {
378
+ const base = (ts ?? '')
379
+ .trim()
380
+ .replace(/\s*\|\s*null\b/g, '')
381
+ .trim();
382
+ const m = base.match(/^Array<(.+)>$/) ?? base.match(/^(.+)\[\]$/);
383
+ return m !== null ? m[1].trim() : base;
384
+ };
385
+
347
386
  const result = await template({
348
387
  ...data,
349
388
  pascalCase,
@@ -365,6 +404,11 @@ async function renderTemplate(
365
404
  fieldUsesJSON,
366
405
  fieldUsesFloat,
367
406
  collectEnumNames,
407
+ messageNames,
408
+ isReferencedMessageType,
409
+ isReferencedMessageTypeArray,
410
+ extractReferencedTypeName,
411
+ referencedTypes: data.referencedTypes,
368
412
  });
369
413
 
370
414
  debugTemplate('Template rendered, output size: %d bytes', result.length);
@@ -455,6 +499,34 @@ async function generateFileForTemplate(
455
499
  return plan;
456
500
  }
457
501
 
502
+ export function resolveReferencedMessageTypes(
503
+ fields: Array<{ name: string; type?: string; tsType?: string }>,
504
+ messagesByName: Map<string, MessageDefinition>,
505
+ visited?: Set<string>,
506
+ ): Array<{ name: string; fields: MessageDefinition['fields'] }> {
507
+ const seen = visited ?? new Set<string>();
508
+ const results: Array<{ name: string; fields: MessageDefinition['fields'] }> = [];
509
+
510
+ for (const field of fields) {
511
+ const raw = field.type ?? field.tsType ?? 'string';
512
+ const stripped = raw.replace(/\s*\|\s*null\b/g, '').trim();
513
+ const arr1 = stripped.match(/^Array<(.+)>$/);
514
+ const arr2 = stripped.match(/^(.+)\[\]$/);
515
+ const base = arr1 ? arr1[1].trim() : arr2 ? arr2[1].trim() : stripped;
516
+
517
+ if (!messagesByName.has(base) || seen.has(base)) continue;
518
+ seen.add(base);
519
+
520
+ const msg = messagesByName.get(base)!;
521
+ const childFields = (msg.fields ?? []).map((f) => ({ name: f.name, type: f.type }));
522
+ const nested = resolveReferencedMessageTypes(childFields, messagesByName, seen);
523
+ results.push(...nested);
524
+ results.push({ name: msg.name, fields: msg.fields });
525
+ }
526
+
527
+ return results;
528
+ }
529
+
458
530
  function extractUsedErrors(gwtMapping: Record<string, (GwtCondition & { failingFields?: string[] })[]>): string[] {
459
531
  debug('Extracting used errors from GWT mapping');
460
532
  const usedErrors = new Set<string>();
@@ -520,6 +592,15 @@ async function prepareTemplateData(
520
592
  const allEventTypes = createEventUnionType(events);
521
593
  const localEvents = getLocalEvents(events);
522
594
 
595
+ const messagesByName = new Map((allMessages ?? []).map((m) => [m.name, m]));
596
+ const targetName = 'server' in slice ? slice.server?.data?.items?.[0]?.target?.name : undefined;
597
+ const queryMessage = targetName != null ? allMessages?.find((m) => m.name === targetName) : undefined;
598
+ const commandFields = filteredCommands.flatMap((c) => c.fields ?? []);
599
+ const referencedTypes = resolveReferencedMessageTypes(
600
+ [...(queryMessage?.fields ?? []), ...commandFields],
601
+ messagesByName,
602
+ );
603
+
523
604
  return {
524
605
  flowName: flow.name,
525
606
  sliceName: slice.name,
@@ -547,6 +628,7 @@ async function prepareTemplateData(
547
628
  allEventTypes,
548
629
  allEventTypesArray,
549
630
  localEvents,
631
+ referencedTypes,
550
632
  };
551
633
  }
552
634
 
@@ -440,4 +440,113 @@ export class AddItemsToCartResolver {
440
440
  );
441
441
  expect(mutationFile?.contents).toContain('@Field(() => Float)');
442
442
  });
443
+
444
+ it('should generate @InputType classes for referenced message types with Input suffix', async () => {
445
+ const spec: SpecsSchema = {
446
+ variant: 'specs',
447
+ narratives: [
448
+ {
449
+ name: 'Shopping',
450
+ slices: [
451
+ {
452
+ type: 'command',
453
+ name: 'Place order',
454
+ client: { specs: [] },
455
+ server: {
456
+ description: '',
457
+ specs: [
458
+ {
459
+ type: 'gherkin',
460
+ feature: '',
461
+ rules: [
462
+ {
463
+ name: 'place order',
464
+ examples: [
465
+ {
466
+ name: 'happy path',
467
+ steps: [
468
+ {
469
+ keyword: 'When',
470
+ text: 'PlaceOrder',
471
+ docString: {
472
+ orderId: 'o-1',
473
+ items: [{ sku: 'abc', quantity: 2 }],
474
+ },
475
+ },
476
+ ],
477
+ },
478
+ ],
479
+ },
480
+ ],
481
+ },
482
+ ],
483
+ },
484
+ },
485
+ ],
486
+ },
487
+ ],
488
+ messages: [
489
+ {
490
+ type: 'state',
491
+ name: 'CartItem',
492
+ fields: [
493
+ { name: 'sku', type: 'string' },
494
+ { name: 'quantity', type: 'number' },
495
+ ],
496
+ },
497
+ {
498
+ type: 'command',
499
+ name: 'PlaceOrder',
500
+ fields: [
501
+ { name: 'orderId', type: 'string', required: true },
502
+ { name: 'items', type: 'Array<CartItem>', required: true },
503
+ ],
504
+ },
505
+ ],
506
+ };
507
+
508
+ const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
509
+ const mutationFile = plans.find(
510
+ (p) => p.outputPath.endsWith('mutation.resolver.ts') && p.contents.includes('export class PlaceOrderResolver'),
511
+ );
512
+
513
+ expect(mutationFile?.contents).toMatchInlineSnapshot(`
514
+ "import { Mutation, Resolver, Arg, Ctx, Field, InputType, Float } from 'type-graphql';
515
+ import { type GraphQLContext, sendCommand, MutationResponse } from '../../../shared';
516
+
517
+ @InputType()
518
+ export class CartItemInput {
519
+ @Field(() => String)
520
+ sku!: string;
521
+
522
+ @Field(() => Float)
523
+ quantity!: number;
524
+ }
525
+
526
+ @InputType()
527
+ export class PlaceOrderInput {
528
+ @Field(() => String)
529
+ orderId!: string;
530
+
531
+ @Field(() => [CartItemInput])
532
+ items!: CartItemInput[];
533
+ }
534
+
535
+ @Resolver()
536
+ export class PlaceOrderResolver {
537
+ @Mutation(() => MutationResponse)
538
+ async placeOrder(
539
+ @Arg('input', () => PlaceOrderInput) input: PlaceOrderInput,
540
+ @Ctx() ctx: GraphQLContext,
541
+ ): Promise<MutationResponse> {
542
+ return await sendCommand(ctx.messageBus, {
543
+ type: 'PlaceOrder',
544
+ kind: 'Command',
545
+ data: { ...input },
546
+ });
547
+ }
548
+ }
549
+ "
550
+ `);
551
+ });
443
552
  });
@@ -1,9 +1,10 @@
1
1
  <%
2
2
  const cmd = commands[0];
3
- const usesDate = cmd.fields.some(f => fieldUsesDate(f.type ?? f.tsType));
4
- const usesJSON = cmd.fields.some(f => fieldUsesJSON(f.type ?? f.tsType));
5
- const usesFloat = cmd.fields.some(f => fieldUsesFloat(f.type ?? f.tsType));
6
- const enumList = collectEnumNames(cmd.fields);
3
+ const refFields = (referencedTypes ?? []).flatMap(rt => rt.fields ?? []);
4
+ const usesDate = [...cmd.fields, ...refFields].some(f => fieldUsesDate(f.type ?? f.tsType));
5
+ const usesJSON = [...cmd.fields, ...refFields].some(f => fieldUsesJSON(f.type ?? f.tsType));
6
+ const usesFloat = [...cmd.fields, ...refFields].some(f => fieldUsesFloat(f.type ?? f.tsType));
7
+ const enumList = collectEnumNames([...cmd.fields, ...refFields]);
7
8
 
8
9
  const embeddedInputs = [];
9
10
  for (const f of cmd.fields) {
@@ -35,6 +36,23 @@ export class <%= typeName %> {
35
36
  <% } %>
36
37
  }
37
38
  <% } %>
39
+ <% for (const ref of (referencedTypes ?? [])) {
40
+ const parsedRefFields = (ref.fields ?? []).map(f => ({
41
+ name: f.name,
42
+ tsType: f.type ?? 'string',
43
+ gqlType: graphqlType(f.type ?? 'string'),
44
+ nullable: isNullable(f.type ?? 'string'),
45
+ }));
46
+ %>
47
+
48
+ @InputType()
49
+ export class <%= ref.name %>Input {
50
+ <% for (const f of parsedRefFields) { %>
51
+ @Field(() => <%= f.gqlType %><%= f.nullable ? ', { nullable: true }' : '' %>)
52
+ <%= f.name %><%= f.nullable ? '?' : '!' %>: <%= toTsFieldType(f.tsType) %>;
53
+ <% } %>
54
+ }
55
+ <% } %>
38
56
 
39
57
  @InputType()
40
58
  export class <%= pascalCase(cmd.type) %>Input {
@@ -49,6 +67,12 @@ if (isInlineObjectArray(tsType)) { %>
49
67
  <% } else if (isInlineObject(tsType)) { %>
50
68
  @Field(() => <%= nestedName %><%= (field.required === false || isNullable(tsType)) ? ', { nullable: true }' : '' %>)
51
69
  <%= field.name %><%= field.required === false ? '?' : '!' %>: <%= nestedName %>;
70
+ <% } else if (isReferencedMessageTypeArray(tsType)) { %>
71
+ @Field(() => [<%= extractReferencedTypeName(tsType) %>Input]<%= (field.required === false || isNullable(tsType)) ? ', { nullable: true }' : '' %>)
72
+ <%= field.name %><%= field.required === false ? '?' : '!' %>: <%= extractReferencedTypeName(tsType) %>Input[];
73
+ <% } else if (isReferencedMessageType(tsType)) { %>
74
+ @Field(() => <%= extractReferencedTypeName(tsType) %>Input<%= (field.required === false || isNullable(tsType)) ? ', { nullable: true }' : '' %>)
75
+ <%= field.name %><%= field.required === false ? '?' : '!' %>: <%= extractReferencedTypeName(tsType) %>Input;
52
76
  <% } else { %>
53
77
  @Field(() => <%= gqlType %><%= (field.required === false || isNullable(tsType)) ? ', { nullable: true }' : '' %>)
54
78
  <%= field.name %><%= field.required === false ? '?' : '!' %>: <%= toTsFieldType(tsType) %>;