@auto-engineer/server-generator-apollo-emmett 1.88.0 → 1.90.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 (64) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +5 -5
  3. package/.turbo/turbo-type-check.log +1 -1
  4. package/CHANGELOG.md +90 -0
  5. package/dist/src/codegen/extract/slice-normalizer.d.ts.map +1 -1
  6. package/dist/src/codegen/extract/slice-normalizer.js +14 -0
  7. package/dist/src/codegen/extract/slice-normalizer.js.map +1 -1
  8. package/dist/src/codegen/extract/type-helpers.d.ts +10 -0
  9. package/dist/src/codegen/extract/type-helpers.d.ts.map +1 -1
  10. package/dist/src/codegen/extract/type-helpers.js +17 -0
  11. package/dist/src/codegen/extract/type-helpers.js.map +1 -1
  12. package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
  13. package/dist/src/codegen/scaffoldFromSchema.js +6 -4
  14. package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
  15. package/dist/src/codegen/templates/command/decide.specs.specs.ts +293 -34
  16. package/dist/src/codegen/templates/command/decide.specs.ts +34 -14
  17. package/dist/src/codegen/templates/command/decide.specs.ts.ejs +47 -14
  18. package/dist/src/codegen/templates/command/decide.ts.ejs +32 -4
  19. package/dist/src/codegen/templates/command/mutation.resolver.specs.ts +72 -1
  20. package/dist/src/codegen/templates/command/mutation.resolver.ts.ejs +1 -1
  21. package/dist/src/codegen/templates/query/projection.specs.specs.ts +124 -0
  22. package/dist/src/codegen/templates/query/projection.specs.ts +20 -0
  23. package/dist/src/codegen/templates/query/projection.specs.ts.ejs +5 -1
  24. package/dist/src/codegen/templates/query/projection.ts.ejs +5 -0
  25. package/dist/src/codegen/templates/query/query.resolver.ts.ejs +1 -1
  26. package/dist/src/codegen/templates/react/react.specs.specs.ts +115 -0
  27. package/dist/src/codegen/templates/react/react.specs.ts +9 -2
  28. package/dist/src/codegen/templates/react/react.specs.ts.ejs +1 -3
  29. package/dist/src/codegen/templates/react/react.ts.ejs +22 -9
  30. package/dist/src/codegen/templates/react/react.ts.specs.ts +253 -0
  31. package/dist/src/codegen/templates/react/register.specs.ts +27 -23
  32. package/dist/src/codegen/templates/react/register.ts.ejs +5 -1
  33. package/dist/tsconfig.tsbuildinfo +1 -1
  34. package/ketchup-plan.md +12 -3
  35. package/package.json +4 -4
  36. package/src/codegen/extract/slice-normalizer.specs.ts +83 -0
  37. package/src/codegen/extract/slice-normalizer.ts +15 -0
  38. package/src/codegen/extract/type-helpers.specs.ts +77 -1
  39. package/src/codegen/extract/type-helpers.ts +23 -0
  40. package/src/codegen/formatTsValueSimple.specs.ts +8 -0
  41. package/src/codegen/scaffoldFromSchema.ts +7 -3
  42. package/src/codegen/templates/command/decide.specs.specs.ts +293 -34
  43. package/src/codegen/templates/command/decide.specs.ts +34 -14
  44. package/src/codegen/templates/command/decide.specs.ts.ejs +47 -14
  45. package/src/codegen/templates/command/decide.ts.ejs +32 -4
  46. package/src/codegen/templates/command/mutation.resolver.specs.ts +72 -1
  47. package/src/codegen/templates/command/mutation.resolver.ts.ejs +1 -1
  48. package/src/codegen/templates/query/projection.specs.specs.ts +124 -0
  49. package/src/codegen/templates/query/projection.specs.ts +20 -0
  50. package/src/codegen/templates/query/projection.specs.ts.ejs +5 -1
  51. package/src/codegen/templates/query/projection.ts.ejs +5 -0
  52. package/src/codegen/templates/query/query.resolver.ts.ejs +1 -1
  53. package/src/codegen/templates/react/react.specs.specs.ts +115 -0
  54. package/src/codegen/templates/react/react.specs.ts +9 -2
  55. package/src/codegen/templates/react/react.specs.ts.ejs +1 -3
  56. package/src/codegen/templates/react/react.ts.ejs +22 -9
  57. package/src/codegen/templates/react/react.ts.specs.ts +253 -0
  58. package/src/codegen/templates/react/register.specs.ts +27 -23
  59. package/src/codegen/templates/react/register.ts.ejs +5 -1
  60. package/dist/src/codegen/extract/graphql.d.ts +0 -14
  61. package/dist/src/codegen/extract/graphql.d.ts.map +0 -1
  62. package/dist/src/codegen/extract/graphql.js +0 -81
  63. package/dist/src/codegen/extract/graphql.js.map +0 -1
  64. package/src/codegen/extract/graphql.ts +0 -103
@@ -64,8 +64,8 @@ case '<%= command %>': {
64
64
  <% if (integrationReturnType) { -%>
65
65
  * - Use `<%= camelCase(integrationReturnType) %>` (integration result) to enrich or filter the output
66
66
  <% } -%>
67
- * - If invalid, throw one of the following domain errors: `NotFoundError`, `ValidationError`, or `IllegalStateError`
68
- * ⚠️ Error constructors: NotFoundError takes { id, type, message? }, while IllegalStateError/ValidationError take string
67
+ * - If invalid, throw one of the following domain errors: `IllegalStateError`<% if (usedErrors.includes('ValidationError')) { %>, `ValidationError`<% } %><% if (usedErrors.includes('NotFoundError')) { %>, `NotFoundError`<% } %>
68
+ * ⚠️ Error constructors: IllegalStateError takes a string message<% if (usedErrors.includes('ValidationError')) { %>, ValidationError takes a string message<% } %><% if (usedErrors.includes('NotFoundError')) { %>, NotFoundError takes { id, type, message? }<% } %>
69
69
  * - If valid, return one or more events with the correct structure
70
70
  *
71
71
  * ⚠️ Only read from inputs — never mutate them. `evolve.ts` handles state updates.
@@ -95,10 +95,38 @@ throw new <%= error.errorType %>('<%- error.message ?? 'Validation failed' %>');
95
95
  }
96
96
  <% } } -%>
97
97
 
98
+ <%
99
+ const cmdFieldSet = new Set((commandSchemasByName?.[command]?.fields || []).map(f => f.name));
100
+ const nonCommandFields = [];
101
+ for (const scenario of scenarios) {
102
+ for (const t of scenario.then.filter(t => 'eventRef' in t)) {
103
+ const eventMsg = (messages || []).find(m => m.name === t.eventRef && m.type === 'event');
104
+ for (const f of (eventMsg?.fields || [])) {
105
+ if (!cmdFieldSet.has(f.name) && !nonCommandFields.some(e => e.name === f.name)) {
106
+ nonCommandFields.push(f);
107
+ }
108
+ }
109
+ }
110
+ }
111
+ -%>
112
+ <% if (nonCommandFields.length > 0) { -%>
113
+ // ⚠️ REQUIRED: Your return value MUST include ALL fields defined in the event type.
114
+ // Tests use partial matching and may not check every field — passing tests does NOT mean all fields are present.
115
+ // Do NOT use 'as <%= fallbackEventTypes[0] ? pascalCase(fallbackEventTypes[0]) : 'EventType' %>' to silence missing fields.
116
+ //
117
+ // Fields from command input → use ...command.data or command.data.<fieldName>
118
+ // Fields NOT in command input → produce dynamically (never hardcode):
119
+ <% for (const f of nonCommandFields) { -%>
120
+ // <%= f.name %>: <%= f.type || f.tsType %> — derive from _state, generate at runtime (e.g., crypto.randomUUID()), or compute from command.data
121
+ <% } -%>
122
+ <% } else { -%>
123
+ // All event fields come from command input — use ...command.data to pass them through.
124
+ <% } -%>
125
+
98
126
  // return {
99
127
  // type: '<%= fallbackEventTypes[0] ?? 'TODO_EVENT_TYPE' %>',
100
- // data: { ...command.data },
101
- // } as <%= fallbackEventTypes[0] ? pascalCase(fallbackEventTypes[0]) : 'TODO_EVENT_TYPE' %>;
128
+ // data: { ...command.data<%= nonCommandFields.length > 0 ? `, /* + dynamically produce: ${nonCommandFields.map(f => f.name).join(', ')} */` : '' %> },
129
+ // };
102
130
 
103
131
  throw new IllegalStateError('Not yet implemented: ' + command.type);
104
132
  }
@@ -150,7 +150,7 @@ describe('mutation.resolver.ts.ejs', () => {
150
150
  specs: [],
151
151
  },
152
152
  request:
153
- 'mutation AnswerQuestion($input: AnswerQuestionInput!) {\\n answerQuestion(input: $input) {\\n success\\n }\\n}',
153
+ 'mutation AnswerQuestion($input: AnswerQuestionInput!) {\n answerQuestion(input: $input) {\n success\n }\n}',
154
154
  server: {
155
155
  description: '',
156
156
  data: {
@@ -549,4 +549,75 @@ export class AddItemsToCartResolver {
549
549
  "
550
550
  `);
551
551
  });
552
+
553
+ it('uses parsedRequest operationName when it differs from camelCase(cmd.type)', async () => {
554
+ const spec: SpecsSchema = {
555
+ variant: 'specs',
556
+ narratives: [
557
+ {
558
+ name: 'Questionnaires',
559
+ slices: [
560
+ {
561
+ name: 'submit answer',
562
+ type: 'command',
563
+ client: { specs: [] },
564
+ request:
565
+ 'mutation SubmitAnswer($input: SubmitAnswerInput!) {\n submitQuestionnaireAnswer(input: $input) {\n success\n }\n}',
566
+ server: {
567
+ description: '',
568
+ data: {
569
+ items: [
570
+ {
571
+ target: { type: 'Event', name: 'AnswerSubmitted' },
572
+ destination: { type: 'stream', pattern: 'questionnaire-participantId' },
573
+ },
574
+ ],
575
+ },
576
+ specs: [
577
+ {
578
+ type: 'gherkin',
579
+ feature: '',
580
+ rules: [
581
+ {
582
+ name: 'submit answer',
583
+ examples: [
584
+ {
585
+ name: 'happy path',
586
+ steps: [
587
+ {
588
+ keyword: 'When',
589
+ text: 'SubmitAnswer',
590
+ docString: { participantId: 'p-1', answer: 'Yes' },
591
+ },
592
+ ],
593
+ },
594
+ ],
595
+ },
596
+ ],
597
+ },
598
+ ],
599
+ },
600
+ },
601
+ ],
602
+ },
603
+ ],
604
+ messages: [
605
+ {
606
+ type: 'command',
607
+ name: 'SubmitAnswer',
608
+ fields: [
609
+ { name: 'participantId', type: 'string', required: true },
610
+ { name: 'answer', type: 'string', required: true },
611
+ ],
612
+ },
613
+ ],
614
+ };
615
+
616
+ const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
617
+ const mutationFile = plans.find(
618
+ (p) => p.outputPath.endsWith('mutation.resolver.ts') && p.contents.includes('export class SubmitAnswerResolver'),
619
+ );
620
+
621
+ expect(mutationFile?.contents).toContain('async submitQuestionnaireAnswer(');
622
+ });
552
623
  });
@@ -82,7 +82,7 @@ if (isInlineObjectArray(tsType)) { %>
82
82
  @Resolver()
83
83
  export class <%= pascalCase(cmd.type) %>Resolver {
84
84
  @Mutation(() => MutationResponse)
85
- async <%= camelCase(cmd.type) %>(
85
+ async <%= parsedRequest?.operationName ?? camelCase(cmd.type) %>(
86
86
  @Arg('input', () => <%= pascalCase(cmd.type) %>Input) input: <%= pascalCase(cmd.type) %>Input,
87
87
  @Ctx() ctx: GraphQLContext,
88
88
  ): Promise<MutationResponse> {
@@ -1567,6 +1567,11 @@ describe('projection.specs.ts.ejs', () => {
1567
1567
  case 'WorkoutRecorded': {
1568
1568
  /**
1569
1569
  * ## IMPLEMENTATION INSTRUCTIONS ##
1570
+ *
1571
+ * Derive ALL field values from event.data or existing document state.
1572
+ * NEVER hardcode constant values — every output field must trace to an input.
1573
+ * Preserve all import paths above — they are generated from the model.
1574
+ *
1570
1575
  * Implement how this event updates the projection.
1571
1576
  *
1572
1577
  * **Internal State Pattern (extends, never replaces the imported type):**
@@ -2094,6 +2099,125 @@ describe('projection.specs.ts.ejs', () => {
2094
2099
  expect(specFile?.contents).not.toContain('"[{\\"appointmentId\\"');
2095
2100
  });
2096
2101
 
2102
+ it('should serialize array values correctly when event field is missing from schema', async () => {
2103
+ const spec: SpecsSchema = {
2104
+ variant: 'specs',
2105
+ narratives: [
2106
+ {
2107
+ name: 'fitness-flow',
2108
+ slices: [
2109
+ {
2110
+ type: 'command',
2111
+ name: 'update-leaderboard',
2112
+ stream: 'leaderboard-${week}',
2113
+ client: { specs: [] },
2114
+ server: {
2115
+ description: '',
2116
+ specs: [
2117
+ {
2118
+ type: 'gherkin',
2119
+ feature: 'Update leaderboard',
2120
+ rules: [
2121
+ {
2122
+ name: 'Should update leaderboard',
2123
+ examples: [
2124
+ {
2125
+ name: 'Leaderboard updated',
2126
+ steps: [
2127
+ {
2128
+ keyword: 'When',
2129
+ text: 'UpdateLeaderboard',
2130
+ docString: { week: 'week-1' },
2131
+ },
2132
+ {
2133
+ keyword: 'Then',
2134
+ text: 'LeaderboardUpdated',
2135
+ docString: { week: 'week-1', rankings: [{ memberId: 'm1', points: 100 }] },
2136
+ },
2137
+ ],
2138
+ },
2139
+ ],
2140
+ },
2141
+ ],
2142
+ },
2143
+ ],
2144
+ },
2145
+ },
2146
+ {
2147
+ type: 'query',
2148
+ name: 'view-leaderboard',
2149
+ stream: 'leaderboard',
2150
+ client: { specs: [] },
2151
+ server: {
2152
+ description: '',
2153
+ data: {
2154
+ items: [
2155
+ {
2156
+ target: { type: 'State', name: 'LeaderboardStats' },
2157
+ origin: { type: 'projection', name: 'LeaderboardProjection', idField: 'week' },
2158
+ },
2159
+ ],
2160
+ },
2161
+ specs: [
2162
+ {
2163
+ type: 'gherkin',
2164
+ feature: 'View leaderboard',
2165
+ rules: [
2166
+ {
2167
+ name: 'Should show leaderboard',
2168
+ examples: [
2169
+ {
2170
+ name: 'Shows rankings after update',
2171
+ steps: [
2172
+ {
2173
+ keyword: 'When',
2174
+ text: 'LeaderboardUpdated',
2175
+ docString: { week: 'week-1', rankings: [{ memberId: 'm1', points: 100 }] },
2176
+ },
2177
+ {
2178
+ keyword: 'Then',
2179
+ text: 'LeaderboardStats',
2180
+ docString: { week: 'week-1', rankings: [{ memberId: 'm1', points: 100 }] },
2181
+ },
2182
+ ],
2183
+ },
2184
+ ],
2185
+ },
2186
+ ],
2187
+ },
2188
+ ],
2189
+ },
2190
+ },
2191
+ ],
2192
+ },
2193
+ ],
2194
+ messages: [
2195
+ {
2196
+ type: 'command',
2197
+ name: 'UpdateLeaderboard',
2198
+ fields: [{ name: 'week', type: 'string', required: true }],
2199
+ },
2200
+ {
2201
+ type: 'event',
2202
+ name: 'LeaderboardUpdated',
2203
+ source: 'internal',
2204
+ fields: [{ name: 'week', type: 'string', required: true }],
2205
+ },
2206
+ {
2207
+ type: 'state',
2208
+ name: 'LeaderboardStats',
2209
+ fields: [{ name: 'week', type: 'string', required: true }],
2210
+ },
2211
+ ],
2212
+ };
2213
+
2214
+ const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
2215
+ const specFile = plans.find((p) => p.outputPath.endsWith('view-leaderboard/projection.specs.ts'));
2216
+
2217
+ expect(specFile?.contents).not.toContain('[object Object]');
2218
+ expect(specFile?.contents).toContain('week');
2219
+ });
2220
+
2097
2221
  it('should resolve idField value from Given/When events when not in Then state data', async () => {
2098
2222
  const spec: SpecsSchema = {
2099
2223
  variant: 'specs',
@@ -239,6 +239,11 @@ describe('projection.ts.ejs', () => {
239
239
  case 'ListingCreated': {
240
240
  /**
241
241
  * ## IMPLEMENTATION INSTRUCTIONS ##
242
+ *
243
+ * Derive ALL field values from event.data or existing document state.
244
+ * NEVER hardcode constant values — every output field must trace to an input.
245
+ * Preserve all import paths above — they are generated from the model.
246
+ *
242
247
  * Implement how this event updates the projection.
243
248
  *
244
249
  * **Internal State Pattern (extends, never replaces the imported type):**
@@ -273,6 +278,11 @@ describe('projection.ts.ejs', () => {
273
278
  case 'ListingRemoved': {
274
279
  /**
275
280
  * ## IMPLEMENTATION INSTRUCTIONS ##
281
+ *
282
+ * Derive ALL field values from event.data or existing document state.
283
+ * NEVER hardcode constant values — every output field must trace to an input.
284
+ * Preserve all import paths above — they are generated from the model.
285
+ *
276
286
  * This event might indicate removal of a AvailableListings.
277
287
  *
278
288
  * - If the intent is to **remove the document**, return \`null\`.
@@ -568,6 +578,11 @@ describe('projection.ts.ejs', () => {
568
578
  case 'TodoAdded': {
569
579
  /**
570
580
  * ## IMPLEMENTATION INSTRUCTIONS ##
581
+ *
582
+ * Derive ALL field values from event.data or existing document state.
583
+ * NEVER hardcode constant values — every output field must trace to an input.
584
+ * Preserve all import paths above — they are generated from the model.
585
+ *
571
586
  * **SINGLETON AGGREGATION PATTERN**
572
587
  *
573
588
  * This projection maintains ONE document aggregating data from MANY entities.
@@ -784,6 +799,11 @@ describe('projection.ts.ejs', () => {
784
799
  case 'UserJoinedProject': {
785
800
  /**
786
801
  * ## IMPLEMENTATION INSTRUCTIONS ##
802
+ *
803
+ * Derive ALL field values from event.data or existing document state.
804
+ * NEVER hardcode constant values — every output field must trace to an input.
805
+ * Preserve all import paths above — they are generated from the model.
806
+ *
787
807
  * **COMPOSITE KEY PROJECTION**
788
808
  *
789
809
  * This projection uses a composite key: userId + projectId
@@ -35,7 +35,7 @@ function isQueryAction(whenText, queryName) {
35
35
 
36
36
  function formatSpecValue(value, tsType) {
37
37
  if (value === null || value === undefined) return 'null';
38
- if (tsType === 'string' || tsType === 'ID') return `'${value}'`;
38
+ if ((tsType === 'string' || tsType === 'ID') && typeof value === 'string') return `'${value}'`;
39
39
  if (tsType === 'number' || tsType === 'boolean') return String(value);
40
40
  if (tsType === 'Date') return `new Date('${value}')`;
41
41
  if (Array.isArray(value)) {
@@ -60,6 +60,10 @@ function formatSpecValue(value, tsType) {
60
60
  });
61
61
  return `{ ${entries.join(', ')} }`;
62
62
  }
63
+ const entries = Object.entries(value).map(([k, v]) =>
64
+ `${k}: ${formatSpecValue(v, typeof v)}`
65
+ );
66
+ return `{ ${entries.join(', ')} }`;
63
67
  }
64
68
  if (typeof value === 'string' && (tsType.includes('[]') || tsType.startsWith('Array<'))) {
65
69
  try {
@@ -120,6 +120,11 @@ switch (event.type) {
120
120
  case '<%= event.type %>': {
121
121
  /**
122
122
  * ## IMPLEMENTATION INSTRUCTIONS ##
123
+ *
124
+ * Derive ALL field values from event.data or existing document state.
125
+ * NEVER hardcode constant values — every output field must trace to an input.
126
+ * Preserve all import paths above — they are generated from the model.
127
+ *
123
128
  <% if (isSingleton) { -%>
124
129
  * **SINGLETON AGGREGATION PATTERN**
125
130
  *
@@ -2,7 +2,7 @@
2
2
  const target = slice?.server?.data?.items?.[0]?.target;
3
3
  const projection = slice?.server?.data?.items?.[0]?.origin;
4
4
  const isSingleton = projection?.singleton === true;
5
- const queryName = parsedRequest?.queryName ?? camelCase(sliceName);
5
+ const queryName = parsedRequest?.operationName ?? camelCase(sliceName);
6
6
  const viewType = target?.name ? pascalCase(target.name) : 'UnknownView';
7
7
  const collectionName = projectionName || 'unknown-collection';
8
8
  const message = messages?.find(m => m.name === viewType);
@@ -369,6 +369,121 @@ describe('react.specs.ts.ejs (react slice)', () => {
369
369
  expect(specFile?.contents).not.toContain('tipAmount: "5.00"');
370
370
  });
371
371
 
372
+ it('should exclude event And-steps from ReactorCommand type and then assertions', async () => {
373
+ const spec: SpecsSchema = {
374
+ variant: 'specs',
375
+ narratives: [
376
+ {
377
+ name: 'notification flow',
378
+ slices: [
379
+ {
380
+ type: 'command',
381
+ name: 'earn points',
382
+ client: { specs: [] },
383
+ server: {
384
+ description: '',
385
+ specs: [
386
+ {
387
+ type: 'gherkin',
388
+ feature: 'Earn points',
389
+ rules: [
390
+ {
391
+ name: 'Should earn',
392
+ examples: [
393
+ {
394
+ name: 'Points earned',
395
+ steps: [
396
+ { keyword: 'When', text: 'EarnPoints', docString: { memberId: 'm1', amount: 10 } },
397
+ { keyword: 'Then', text: 'PointsEarned', docString: { memberId: 'm1', amount: 10 } },
398
+ ],
399
+ },
400
+ ],
401
+ },
402
+ ],
403
+ },
404
+ ],
405
+ },
406
+ },
407
+ {
408
+ type: 'react',
409
+ name: 'notify milestone',
410
+ server: {
411
+ description: 'Notifies on milestone',
412
+ data: {
413
+ items: [
414
+ {
415
+ target: { type: 'Command', name: 'SendNotification' },
416
+ destination: { type: 'stream', pattern: 'notif-${memberId}' },
417
+ },
418
+ ],
419
+ },
420
+ specs: [
421
+ {
422
+ type: 'gherkin',
423
+ feature: 'Milestone notification',
424
+ rules: [
425
+ {
426
+ name: 'Should notify',
427
+ examples: [
428
+ {
429
+ name: 'Milestone reached',
430
+ steps: [
431
+ { keyword: 'When', text: 'PointsEarned', docString: { memberId: 'm1', amount: 10 } },
432
+ { keyword: 'Then', text: 'MilestoneNotified', docString: { memberId: 'm1' } },
433
+ { keyword: 'And', text: 'SendNotification', docString: { memberId: 'm1' } },
434
+ ],
435
+ },
436
+ ],
437
+ },
438
+ ],
439
+ },
440
+ ],
441
+ },
442
+ },
443
+ ],
444
+ },
445
+ ],
446
+ messages: [
447
+ {
448
+ type: 'command',
449
+ name: 'EarnPoints',
450
+ fields: [
451
+ { name: 'memberId', type: 'string', required: true },
452
+ { name: 'amount', type: 'number', required: true },
453
+ ],
454
+ },
455
+ {
456
+ type: 'event',
457
+ name: 'PointsEarned',
458
+ source: 'internal',
459
+ fields: [
460
+ { name: 'memberId', type: 'string', required: true },
461
+ { name: 'amount', type: 'number', required: true },
462
+ ],
463
+ },
464
+ {
465
+ type: 'command',
466
+ name: 'SendNotification',
467
+ fields: [{ name: 'memberId', type: 'string', required: true }],
468
+ },
469
+ {
470
+ type: 'event',
471
+ name: 'MilestoneNotified',
472
+ source: 'internal',
473
+ fields: [{ name: 'memberId', type: 'string', required: true }],
474
+ },
475
+ ],
476
+ };
477
+
478
+ const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
479
+ const specFile = plans.find((p) => p.outputPath.endsWith('notify-milestone/react.specs.ts'));
480
+
481
+ expect(specFile?.contents).toContain('type ReactorCommand = SendNotification;');
482
+ expect(specFile?.contents).not.toContain('MilestoneNotified');
483
+ expect(specFile?.contents).toContain("type: 'SendNotification'");
484
+ expect(specFile?.contents).toContain('.then({');
485
+ });
486
+
372
487
  it('should seed event store with Given state data via appendToStream', async () => {
373
488
  const spec: SpecsSchema = {
374
489
  variant: 'specs',
@@ -248,8 +248,15 @@ describe('handle.ts.ejs (react slice)', () => {
248
248
  /**
249
249
  * ## IMPLEMENTATION INSTRUCTIONS ##
250
250
  *
251
- * - Review the generated send call below and adjust if needed.
252
- * - Add business logic (validation, conditional sends) as required.
251
+ * Complete the command send below. Field sources are pre-classified:
252
+ * - \`event.data.<field>\` already wired from the triggering event.
253
+ * - \`<stateVar>.<field>\` → already wired from loaded aggregate state.
254
+ * - \`undefined, // TODO: source unknown\` → MUST be dynamically derived:
255
+ * compute from event.data, loaded state, or runtime logic.
256
+ * NEVER hardcode values copied from test assertions.
257
+ *
258
+ * Preserve all import paths above — they are generated from the model.
259
+ * Add business logic (validation, conditional sends) as needed.
253
260
  */
254
261
 
255
262
  // Event (BookingRequested) fields: bookingId: string, hostId: string, message: string
@@ -122,9 +122,7 @@ describe('<%= ruleDescription %>', () => {
122
122
  for (const gs of givenStates) {
123
123
  const stateSchema = states.find(s => s.type === gs.eventRef);
124
124
  const eventDef = messages.find(m => m.name === exampleEvent.eventRef);
125
- const stateFieldNames = (stateSchema?.fields || []).map(f => f.name);
126
- const eventFieldNames = (eventDef?.fields || []).map(f => f.name);
127
- const linkingField = stateFieldNames.find(f => eventFieldNames.includes(f));
125
+ const linkingField = findPrimitiveLinkingField(stateSchema?.fields || [], eventDef?.fields || []);
128
126
  if (linkingField) {
129
127
  const linkingValue = gs.exampleData[linkingField];
130
128
  -%>
@@ -39,15 +39,30 @@ connectionOptions: {
39
39
  database,
40
40
  },
41
41
  eachMessage: async (event, context): Promise<MessageHandlerResult> => {
42
+ <%
43
+ const eventDef = messages.find(m => m.name === eventType);
44
+ const commandDef = messages.find(m => m.name === commandType && m.type === 'command');
45
+ const willHaveAggregateStream = states.some(state =>
46
+ findPrimitiveLinkingField(state.fields, eventDef?.fields || []) !== undefined
47
+ );
48
+ -%>
42
49
  /**
43
50
  * ## IMPLEMENTATION INSTRUCTIONS ##
44
51
  *
45
- * - Review the generated send call below and adjust if needed.
46
- * - Add business logic (validation, conditional sends) as required.
52
+ * Complete the command send below. Field sources are pre-classified:
53
+ * - `event.data.<field>` already wired from the triggering event.
54
+ * - `<stateVar>.<field>` → already wired from loaded aggregate state.
55
+ * - `undefined, // TODO: source unknown` → MUST be dynamically derived:
56
+ * compute from event.data, loaded state, or runtime logic.
57
+ * NEVER hardcode values copied from test assertions.
58
+ *
59
+ * Preserve all import paths above — they are generated from the model.
60
+ <% if (willHaveAggregateStream) { -%>
61
+ * Do NOT modify or remove aggregateStream calls — they load required state.
62
+ <% } -%>
63
+ * Add business logic (validation, conditional sends) as needed.
47
64
  */
48
65
  <%
49
- const eventDef = messages.find(m => m.name === eventType);
50
- const commandDef = messages.find(m => m.name === commandType && m.type === 'command');
51
66
  const commandFields = (commandDef?.fields || []);
52
67
  const eventFieldSet = new Set((eventDef?.fields || []).map(f => f.name));
53
68
  const stateFieldSources = {};
@@ -61,9 +76,7 @@ const stateFieldSources = {};
61
76
  <% if (states.length > 0) {
62
77
  let hasAggregateStream = false;
63
78
  for (const state of states) {
64
- const stateFieldNames = state.fields.map(f => f.name);
65
- const eventFieldNames = (eventDef?.fields || []).map(f => f.name);
66
- const linkingField = stateFieldNames.find(f => eventFieldNames.includes(f));
79
+ const linkingField = findPrimitiveLinkingField(state.fields, eventDef?.fields || []);
67
80
  if (linkingField) {
68
81
  hasAggregateStream = true;
69
82
  const varName = camelCase(state.type);
@@ -75,8 +88,8 @@ const stateFieldSources = {};
75
88
  const { state: <%= varName %> } = await eventStore.aggregateStream(
76
89
  '<%= state.type %>-' + event.data.<%= linkingField %>,
77
90
  {
78
- evolve: (currentState, evt) => ({ ...currentState, ...evt.data }),
79
- initialState: () => ({}),
91
+ evolve: (currentState: Record<string, unknown>, evt: { type: string; data: Record<string, unknown> }) => ({ ...currentState, ...evt.data }),
92
+ initialState: (): Record<string, unknown> => ({}),
80
93
  },
81
94
  );
82
95
  // <%= state.type %> fields: <%= state.fields.map(f => f.name).join(', ') %>