@auto-engineer/server-generator-apollo-emmett 1.124.0 → 1.125.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 (48) 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 +56 -0
  5. package/dist/src/codegen/extract/type-helpers.d.ts.map +1 -1
  6. package/dist/src/codegen/extract/type-helpers.js +7 -5
  7. package/dist/src/codegen/extract/type-helpers.js.map +1 -1
  8. package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
  9. package/dist/src/codegen/scaffoldFromSchema.js +19 -1
  10. package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
  11. package/dist/src/codegen/templates/command/mutation.resolver.specs.ts +0 -1
  12. package/dist/src/codegen/templates/query/query.resolver.specs.ts +8 -9
  13. package/dist/src/codegen/templates/query/query.resolver.ts.ejs +9 -3
  14. package/dist/src/codegen/templates/react/react.specs.specs.ts +3 -3
  15. package/dist/src/codegen/templates/react/react.specs.ts +2 -2
  16. package/dist/src/codegen/templates/react/react.specs.ts.ejs +2 -2
  17. package/dist/src/codegen/templates/react/react.ts.ejs +138 -64
  18. package/dist/src/codegen/templates/react/react.ts.specs.ts +243 -1
  19. package/dist/src/codegen/templates/react/register.specs.ts +281 -14
  20. package/dist/src/codegen/templates/react/register.ts.ejs +100 -48
  21. package/dist/src/commands/generate-server.d.ts +1 -0
  22. package/dist/src/commands/generate-server.d.ts.map +1 -1
  23. package/dist/src/commands/generate-server.js +18 -0
  24. package/dist/src/commands/generate-server.js.map +1 -1
  25. package/dist/src/domain/shared/reactorSpecification.d.ts +5 -5
  26. package/dist/src/domain/shared/reactorSpecification.d.ts.map +1 -1
  27. package/dist/src/domain/shared/reactorSpecification.js +1 -2
  28. package/dist/src/domain/shared/reactorSpecification.js.map +1 -1
  29. package/dist/src/domain/shared/reactorSpecification.ts +7 -10
  30. package/dist/tsconfig.tsbuildinfo +1 -1
  31. package/ketchup-plan.md +4 -30
  32. package/package.json +4 -4
  33. package/src/codegen/extract/type-helpers.specs.ts +50 -1
  34. package/src/codegen/extract/type-helpers.ts +6 -3
  35. package/src/codegen/scaffoldFromSchema.ts +21 -1
  36. package/src/codegen/templates/command/mutation.resolver.specs.ts +0 -1
  37. package/src/codegen/templates/query/query.resolver.specs.ts +8 -9
  38. package/src/codegen/templates/query/query.resolver.ts.ejs +9 -3
  39. package/src/codegen/templates/react/react.specs.specs.ts +3 -3
  40. package/src/codegen/templates/react/react.specs.ts +2 -2
  41. package/src/codegen/templates/react/react.specs.ts.ejs +2 -2
  42. package/src/codegen/templates/react/react.ts.ejs +138 -64
  43. package/src/codegen/templates/react/react.ts.specs.ts +243 -1
  44. package/src/codegen/templates/react/register.specs.ts +281 -14
  45. package/src/codegen/templates/react/register.ts.ejs +100 -48
  46. package/src/commands/generate-server.specs.ts +32 -0
  47. package/src/commands/generate-server.ts +20 -0
  48. package/src/domain/shared/reactorSpecification.ts +7 -10
package/ketchup-plan.md CHANGED
@@ -1,37 +1,11 @@
1
- # Ketchup Plan: Fix generator template bugs found in typical server
1
+ # Ketchup Plan: Fix Outstanding Issues from Typical Server Generation
2
2
 
3
3
  ## TODO
4
4
 
5
5
  (none)
6
6
 
7
- ## DONE (Round 3)
8
-
9
- - [x] Burst 30: Fix 12 — Exclude custom GraphQL input types from enum imports in query.resolver.ts.ejs
10
- - [x] Burst 29: Fix 11 — SKIPPED: requires modifying standalone types.ts (blocked by type-organization rule); output is identical so no behavioral impact
11
- - [x] Burst 28: Fix 10 — Remove "valid" qualifier from decide.specs.ts.ejs test descriptions
12
- - [x] Burst 27: Fix 9 — Map custom GraphQL input type args to GraphQLJSON in query.resolver.ts.ejs
13
-
14
- - [x] Burst 26: Add fart-model test for query-arg-differs-from-projection case
15
- - [x] Burst 25: Exclude query arg fields from projection spec expected state (6da49a58)
16
-
17
7
  ## DONE
18
8
 
19
- - [x] Burst 24: Fix handle.ts multi-command stream pattern
20
- - [x] Burst 23: Fix decide default case + add discriminated union narrowing guidance (a24a654f)
21
- - [x] Burst 22: Add null-document guidance to projection template (0d1d2007)
22
- - [x] Burst 21: Add discriminated union guidance to evolve template (7abc038c)
23
- - [x] Burst 20: Fill missing inline object fields with type defaults (b39bfd1b)
24
- - [x] Burst 19: Fix formatTsValue to handle inline object types and Array<T> syntax (df459448)
25
-
26
- - [x] Burst 18: Filter events from react Then assertions (cb94eb5d)
27
- - [x] Burst 1: Slim ReadModel to find + findOne, update EJS template API docs, update all inline snapshots
28
- - [x] Burst 8: Return array `idField` natively from extraction (d4633c71)
29
- - [x] Burst 9: Fix `findOne` fallback with safe value resolution (8d3ee9cd)
30
- - [x] Burst 10: Derive stable `metadata.now` with narrow trigger (b7bfd7bf)
31
- - [x] Burst 11: Omit non-command fields from Then assertions + scaffold annotations (c6953364)
32
- - [x] Burst 12: Implementer system prompt — anti-hardcoding rules for decide functions
33
- - [x] Burst 13: Fix formatSpecValue and formatTsValueSimple value-type guards (2412e231)
34
- - [x] Burst 14: Embed implementation instructions in scaffold templates (f211bd90)
35
- - [x] Burst 15: Add expectEvents typing shim to decide.specs.ts.ejs (681db5a5)
36
- - [x] Burst 16: Type-annotate aggregateStream callbacks + dynamic error listing + strengthen instructions (8693a4b3)
37
- - [x] Burst 17: Filter linking field to primitive types only — skip array/object fields in react template linking (d48982d5)
9
+ - [x] Burst 1: Align ReactorLike return type with MessageHandlerResult (3b1217fc)
10
+ - [x] Burst 2: Rename `then` `thenSends` to eliminate thenable risk (c65fa28d)
11
+ - [x] Burst 3: Prefix unused aggregateStream state var with `_` (04fd4cbb)
package/package.json CHANGED
@@ -32,8 +32,8 @@
32
32
  "uuid": "^13.0.0",
33
33
  "web-streams-polyfill": "^4.1.0",
34
34
  "zod": "^3.22.4",
35
- "@auto-engineer/message-bus": "1.124.0",
36
- "@auto-engineer/narrative": "1.124.0"
35
+ "@auto-engineer/narrative": "1.125.0",
36
+ "@auto-engineer/message-bus": "1.125.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.124.0"
47
+ "@auto-engineer/cli": "1.125.0"
48
48
  },
49
- "version": "1.124.0",
49
+ "version": "1.125.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",
@@ -1,6 +1,12 @@
1
1
  import { parseInlineObjectFields } from '@auto-engineer/narrative';
2
2
  import { describe, expect, it } from 'vitest';
3
- import { findPrimitiveLinkingField, isPrimitiveTsType, isValidTsIdentifier, sanitizeFieldType } from './type-helpers';
3
+ import {
4
+ createFieldUsesJSON,
5
+ findPrimitiveLinkingField,
6
+ isPrimitiveTsType,
7
+ isValidTsIdentifier,
8
+ sanitizeFieldType,
9
+ } from './type-helpers';
4
10
 
5
11
  describe('parseInlineObjectFields', () => {
6
12
  it('should parse simple inline object fields', () => {
@@ -165,3 +171,46 @@ describe('findPrimitiveLinkingField', () => {
165
171
  expect(findPrimitiveLinkingField([{ name: 'id', type: 'string' }], [{ name: 'id', type: 'string' }])).toBe('id');
166
172
  });
167
173
  });
174
+
175
+ describe('createFieldUsesJSON', () => {
176
+ const stubGraphqlType = (ts: string): string => {
177
+ if (ts === 'unknown' || ts === 'any' || ts === 'object') return 'GraphQLJSON';
178
+ if (ts.startsWith('Record<')) return 'GraphQLJSON';
179
+ if (ts.startsWith('{') || ts.startsWith('Array<{')) return 'GraphQLJSON';
180
+ return ts;
181
+ };
182
+
183
+ const fieldUsesJSON = createFieldUsesJSON(stubGraphqlType);
184
+
185
+ it('returns false for inline object array without JSON subfields', () => {
186
+ expect(fieldUsesJSON('Array<{ exercise: string; sets: number }>')).toBe(false);
187
+ });
188
+
189
+ it('returns true for inline object with unknown subfield', () => {
190
+ expect(fieldUsesJSON('{ data: unknown }')).toBe(true);
191
+ });
192
+
193
+ it('returns true for non-inline JSON type', () => {
194
+ expect(fieldUsesJSON('unknown')).toBe(true);
195
+ });
196
+
197
+ it('returns true for Record with unknown value', () => {
198
+ expect(fieldUsesJSON('Record<string, unknown>')).toBe(true);
199
+ });
200
+
201
+ it('returns false for primitive type', () => {
202
+ expect(fieldUsesJSON('string')).toBe(false);
203
+ });
204
+
205
+ it('returns true for inline object array with nested inline object subfield', () => {
206
+ expect(fieldUsesJSON('Array<{ workoutId: string; exercises: Array<{ exercise: string }> }>')).toBe(true);
207
+ });
208
+
209
+ it('returns true for inline object with nested inline object subfield', () => {
210
+ expect(fieldUsesJSON('{ sessions: Array<{ id: string; reps: number }> }')).toBe(true);
211
+ });
212
+
213
+ it('returns false for inline object with only primitive subfields', () => {
214
+ expect(fieldUsesJSON('{ name: string; count: number }')).toBe(false);
215
+ });
216
+ });
@@ -58,10 +58,13 @@ export function createFieldUsesDate(graphqlType: (ts: string) => string) {
58
58
  export function createFieldUsesJSON(graphqlType: (ts: string) => string) {
59
59
  return (ts: string): boolean => {
60
60
  const b = baseTs(ts);
61
+ if (isInlineObject(b) || isInlineObjectArray(b)) {
62
+ if (/:\s*(unknown|any|object)\b/.test(b)) return true;
63
+ const fields = parseInlineObjectFields(b);
64
+ return fields.some((f) => isInlineObject(f.tsType) || isInlineObjectArray(f.tsType));
65
+ }
61
66
  const gqlType = graphqlType(b);
62
- if (gqlType.includes('GraphQLJSON') || gqlType.includes('JSON')) return true;
63
- if (isInlineObject(b) || isInlineObjectArray(b)) return /:\s*(unknown|any|object)\b/.test(b);
64
- return false;
67
+ return gqlType.includes('GraphQLJSON') || gqlType.includes('JSON');
65
68
  };
66
69
  }
67
70
 
@@ -676,10 +676,29 @@ async function prepareTemplateData(
676
676
  messagesByName,
677
677
  );
678
678
 
679
+ const normalizedSlice = normalizeSliceForTemplate(slice, allMessages);
680
+
681
+ const normalizedRules = normalizedSlice.server?.specs?.rules ?? [];
682
+ const eventCommandPairs: Array<{ eventType: string; commandType: string }> = [];
683
+ const seenPairKeys = new Set<string>();
684
+ for (const rule of normalizedRules) {
685
+ for (const example of rule.examples ?? []) {
686
+ const w = Array.isArray(example.when) ? example.when[0] : example.when;
687
+ const t = Array.isArray(example.then) ? example.then[0] : example.then;
688
+ if (w && 'eventRef' in w && t && 'commandRef' in t) {
689
+ const key = `${w.eventRef}\u2192${t.commandRef}`;
690
+ if (!seenPairKeys.has(key)) {
691
+ seenPairKeys.add(key);
692
+ eventCommandPairs.push({ eventType: w.eventRef, commandType: t.commandRef });
693
+ }
694
+ }
695
+ }
696
+ }
697
+
679
698
  return {
680
699
  flowName: flow.name,
681
700
  sliceName: slice.name,
682
- slice: normalizeSliceForTemplate(slice, allMessages),
701
+ slice: normalizedSlice,
683
702
  stream: { pattern: streamPattern, id: streamId },
684
703
  commands: filteredCommands,
685
704
  events,
@@ -703,6 +722,7 @@ async function prepareTemplateData(
703
722
  allEventTypesArray,
704
723
  localEvents,
705
724
  referencedTypes,
725
+ eventCommandPairs,
706
726
  };
707
727
  }
708
728
 
@@ -333,7 +333,6 @@ describe('mutation.resolver.ts.ejs', () => {
333
333
 
334
334
  expect(mutationFile?.contents).toMatchInlineSnapshot(`
335
335
  "import { Mutation, Resolver, Arg, Ctx, Field, InputType, Float } from 'type-graphql';
336
- import { GraphQLJSON } from 'graphql-type-json';
337
336
  import { type GraphQLContext, sendCommand, MutationResponse } from '../../../shared';
338
337
 
339
338
  @InputType()
@@ -99,8 +99,8 @@ describe('query.resolver.ts.ejs', () => {
99
99
  async searchProperties(
100
100
  @Ctx() ctx: GraphQLContext,
101
101
  @Arg('location', () => String, { nullable: true }) location?: string,
102
- @Arg('maxPrice', () => Float, { nullable: true }) maxPrice?: number,
103
- @Arg('minGuests', () => Float, { nullable: true }) minGuests?: number,
102
+ @Arg('maxPrice', () => Float, { nullable: true }) _maxPrice?: number,
103
+ @Arg('minGuests', () => Float, { nullable: true }) _minGuests?: number,
104
104
  ): Promise<AvailableListings[]> {
105
105
  const model = new ReadModel<AvailableListings>(ctx.database, 'AvailablePropertiesProjection');
106
106
 
@@ -193,7 +193,6 @@ describe('query.resolver.ts.ejs', () => {
193
193
 
194
194
  expect(resolverFile?.contents).toMatchInlineSnapshot(`
195
195
  "import { Query, Resolver, Arg, Ctx, ObjectType, Field, ID, Float } from 'type-graphql';
196
- import { GraphQLJSON } from 'graphql-type-json';
197
196
  import { type GraphQLContext, ReadModel } from '../../../shared';
198
197
 
199
198
  @ObjectType()
@@ -923,7 +922,7 @@ describe('query.resolver.ts.ejs', () => {
923
922
  @Query(() => [RecipeMatchesRecipes])
924
923
  async recipeMatches(
925
924
  @Ctx() ctx: GraphQLContext,
926
- @Arg('pantryId', () => ID, { nullable: true }) pantryId?: string,
925
+ @Arg('pantryId', () => ID, { nullable: true }) _pantryId?: string,
927
926
  ): Promise<RecipeMatchesRecipes[]> {
928
927
  const model = new ReadModel<RecipeMatchesRecipes>(ctx.database, 'RecipeMatchesProjection');
929
928
 
@@ -934,7 +933,7 @@ describe('query.resolver.ts.ejs', () => {
934
933
  // The scaffolded code below uses find() returning an array.
935
934
  // If this query should return a single item, switch to findOne().
936
935
 
937
- return model.find((item) => {
936
+ return model.find((_item) => {
938
937
  // NOTE: 'pantryId' has no matching field on the state type — add custom filter logic if needed.
939
938
 
940
939
  return true;
@@ -1165,7 +1164,7 @@ describe('query.resolver.ts.ejs', () => {
1165
1164
  @Query(() => [RecipeMatchesRecipes])
1166
1165
  async recipeMatches(
1167
1166
  @Ctx() ctx: GraphQLContext,
1168
- @Arg('pantryId', () => ID, { nullable: true }) pantryId?: string,
1167
+ @Arg('pantryId', () => ID, { nullable: true }) _pantryId?: string,
1169
1168
  ): Promise<RecipeMatchesRecipes[]> {
1170
1169
  const model = new ReadModel<RecipeMatchesRecipes>(ctx.database, 'RecipeMatchesProjection');
1171
1170
 
@@ -1176,7 +1175,7 @@ describe('query.resolver.ts.ejs', () => {
1176
1175
  // The scaffolded code below uses find() returning an array.
1177
1176
  // If this query should return a single item, switch to findOne().
1178
1177
 
1179
- return model.find((item) => {
1178
+ return model.find((_item) => {
1180
1179
  // NOTE: 'pantryId' has no matching field on the state type — add custom filter logic if needed.
1181
1180
 
1182
1181
  return true;
@@ -1246,9 +1245,9 @@ describe('query.resolver.ts.ejs', () => {
1246
1245
 
1247
1246
  expect(resolverFile?.contents).toContain("import { GraphQLJSON } from 'graphql-type-json';");
1248
1247
  expect(resolverFile?.contents).toContain(
1249
- "@Arg('filter', () => GraphQLJSON, { nullable: true }) filter?: Record<string, unknown>",
1248
+ "@Arg('filter', () => GraphQLJSON, { nullable: true }) _filter?: Record<string, unknown>",
1250
1249
  );
1251
- expect(resolverFile?.contents).toContain("@Arg('limit', () => Float, { nullable: true }) limit?: number");
1250
+ expect(resolverFile?.contents).toContain("@Arg('limit', () => Float, { nullable: true }) _limit?: number");
1252
1251
  expect(resolverFile?.contents).not.toContain('ListWorkoutsFilterInput');
1253
1252
  });
1254
1253
  });
@@ -36,6 +36,12 @@ for (const field of messageFields) {
36
36
  }
37
37
  const hasArgs = parsedRequest?.args?.length > 0;
38
38
 
39
+ const stateFieldNames = new Set(messageFields.map(f => f.name));
40
+ const usedArgNames = isSingleton
41
+ ? new Set()
42
+ : new Set((parsedRequest?.args ?? []).filter(a => stateFieldNames.has(a.name)).map(a => a.name));
43
+ const hasMatchingArgs = usedArgNames.size > 0;
44
+
39
45
  const resolveArgTypes = (arg) => {
40
46
  const isCustom = !KNOWN_GQL_SCALARS.has(arg.graphqlType);
41
47
  return {
@@ -118,7 +124,7 @@ async <%= queryName %>(
118
124
  <% for (let i = 0; i < parsedRequest.args.length; i++) {
119
125
  const arg = parsedRequest.args[i];
120
126
  const { gqlType, tsType } = resolveArgTypes(arg);
121
- %> @Arg('<%= arg.name %>', () => <%= gqlType %>, { nullable: true }) <%= arg.name %>?: <%= tsType %><%= i < parsedRequest.args.length - 1 ? ',' : '' %>
127
+ %> @Arg('<%= arg.name %>', () => <%= gqlType %>, { nullable: true }) <%= usedArgNames.has(arg.name) ? arg.name : '_' + arg.name %>?: <%= tsType %><%= i < parsedRequest.args.length - 1 ? ',' : '' %>
122
128
  <% } } %>
123
129
  ): Promise<<%= viewType %>> {
124
130
  const result = await ctx.database.collection<<%= viewType %>>('<%= collectionName %>').findOne();
@@ -160,7 +166,7 @@ async <%= queryName %>(
160
166
  <% for (let i = 0; i < parsedRequest.args.length; i++) {
161
167
  const arg = parsedRequest.args[i];
162
168
  const { gqlType, tsType } = resolveArgTypes(arg);
163
- %> @Arg('<%= arg.name %>', () => <%= gqlType %>, { nullable: true }) <%= arg.name %>?: <%= tsType %><%= i < parsedRequest.args.length - 1 ? ',' : '' %>
169
+ %> @Arg('<%= arg.name %>', () => <%= gqlType %>, { nullable: true }) <%= usedArgNames.has(arg.name) ? arg.name : '_' + arg.name %>?: <%= tsType %><%= i < parsedRequest.args.length - 1 ? ',' : '' %>
164
170
  <% } } %>
165
171
  ): Promise<<%= viewType %>[]> {
166
172
  const model = new ReadModel<<%= viewType %>>(ctx.database, '<%= collectionName %>');
@@ -172,7 +178,7 @@ const model = new ReadModel<<%= viewType %>>(ctx.database, '<%= collectionName %
172
178
  // The scaffolded code below uses find() returning an array.
173
179
  // If this query should return a single item, switch to findOne().
174
180
 
175
- return model.find((<%= hasArgs ? 'item' : '_item' %>) => {
181
+ return model.find((<%= hasMatchingArgs ? 'item' : '_item' %>) => {
176
182
  <% if (parsedRequest?.args?.length) {
177
183
  const stateFieldNames = new Set(messageFields.map(f => f.name));
178
184
  for (const arg of parsedRequest.args) {
@@ -217,7 +217,7 @@ describe('react.specs.ts.ejs (react slice)', () => {
217
217
  },
218
218
  })
219
219
 
220
- .then({
220
+ .thenSends({
221
221
  type: 'NotifyHost',
222
222
  kind: 'Command',
223
223
  data: {
@@ -457,7 +457,7 @@ describe('react.specs.ts.ejs (react slice)', () => {
457
457
  expect(specFile?.contents).toContain('type ReactorCommand = SendNotification;');
458
458
  expect(specFile?.contents).not.toContain('MilestoneNotified');
459
459
  expect(specFile?.contents).toContain("type: 'SendNotification'");
460
- expect(specFile?.contents).toContain('.then({');
460
+ expect(specFile?.contents).toContain('.thenSends({');
461
461
  });
462
462
 
463
463
  it('should seed event store with Given state data via appendToStream', async () => {
@@ -637,7 +637,7 @@ describe('react.specs.ts.ejs (react slice)', () => {
637
637
  },
638
638
  })
639
639
 
640
- .then({
640
+ .thenSends({
641
641
  type: 'NotifyBarber',
642
642
  kind: 'Command',
643
643
  data: {
@@ -233,7 +233,7 @@ describe('handle.ts.ejs (react slice)', () => {
233
233
  const handleFile = plans.find((p) => p.outputPath.endsWith('react.ts'));
234
234
 
235
235
  expect(handleFile?.contents).toMatchInlineSnapshot(`
236
- "import { inMemoryReactor, type MessageHandlerResult, IllegalStateError } from '@event-driven-io/emmett';
236
+ "import { inMemoryReactor, type MessageHandlerResult } from '@event-driven-io/emmett';
237
237
  import type { BookingRequested } from '../guest-submits-booking-request/events';
238
238
  import type { ReactorContext } from '../../../shared';
239
239
 
@@ -244,7 +244,7 @@ describe('handle.ts.ejs (react slice)', () => {
244
244
  connectionOptions: {
245
245
  database,
246
246
  },
247
- eachMessage: async (event, context): Promise<MessageHandlerResult> => {
247
+ eachMessage: async (event): Promise<MessageHandlerResult> => {
248
248
  /**
249
249
  * ## IMPLEMENTATION INSTRUCTIONS ##
250
250
  *
@@ -162,13 +162,13 @@ describe('<%= ruleDescription %>', () => {
162
162
  <% if (thenCommands.length === 1) {
163
163
  const commandSchema = thenCommands[0];
164
164
  %>
165
- .then({
165
+ .thenSends({
166
166
  type: '<%= commandSchema.commandRef %>',
167
167
  kind: 'Command',
168
168
  data: <%- formatDataObject(commandSchema.exampleData, commands.find(c => c.type === commandSchema.commandRef)) %>
169
169
  });
170
170
  <% } else { %>
171
- .then([
171
+ .thenSends([
172
172
  <% for (const cmd of thenCommands) { %>
173
173
  {
174
174
  type: '<%= cmd.commandRef %>',
@@ -1,51 +1,50 @@
1
1
  <%
2
- const specs = slice.server?.specs;
3
- const firstExample = specs?.rules?.flatMap(rule => rule.examples)[0] ?? null;
4
- const gwt = firstExample ? {
5
- given: firstExample.given,
6
- when: firstExample.when,
7
- then: firstExample.then
8
- } : null;
9
- const when = Array.isArray(gwt?.when) ? gwt.when[0] : gwt?.when;
10
- const then = Array.isArray(gwt?.then) ? gwt.then[0] : gwt?.then;
2
+ if (!eventCommandPairs || eventCommandPairs.length === 0) {
3
+ throw new Error(`react.ts.ejs: slice "${slice.name}" has no event→command pairs — check specs`);
4
+ }
11
5
 
12
- const eventType = when?.eventRef;
13
- const commandType = then?.commandRef;
14
- if (!eventType) throw new Error(
15
- `react.ts.ejs: slice "${slice.name}" has no event in .when() check specs`
16
- );
17
- if (!commandType) throw new Error(
18
- `react.ts.ejs: slice "${slice.name}" has no command in .then() — check specs`
19
- );
20
- const event = events.find(e => e.type === eventType);
21
- const isCrossFlow = event?.sourceFlowName && event.sourceFlowName !== flowName;
22
- const eventImportBase = isCrossFlow
23
- ? `../../${toKebabCase(event.sourceFlowName)}/${toKebabCase(event.sourceSliceName)}`
24
- : event?.sourceSliceName ? `../${toKebabCase(event.sourceSliceName)}` : '.';
6
+ const importGroups = new Map();
7
+ for (const pair of eventCommandPairs) {
8
+ const event = events.find(e => e.type === pair.eventType);
9
+ const isCrossFlow = event?.sourceFlowName && event.sourceFlowName !== flowName;
10
+ const importBase = isCrossFlow
11
+ ? `../../${toKebabCase(event.sourceFlowName)}/${toKebabCase(event.sourceSliceName)}`
12
+ : event?.sourceSliceName ? `../${toKebabCase(event.sourceSliceName)}` : '.';
13
+ if (!importGroups.has(importBase)) importGroups.set(importBase, []);
14
+ const typeName = pascalCase(pair.eventType);
15
+ if (!importGroups.get(importBase).includes(typeName)) {
16
+ importGroups.get(importBase).push(typeName);
17
+ }
18
+ }
19
+
20
+ const allEventTypeNames = eventCommandPairs.map(p => pascalCase(p.eventType));
21
+ const uniqueEventTypeNames = [...new Set(allEventTypeNames)];
22
+ const eventUnion = uniqueEventTypeNames.join(' | ');
23
+
24
+ const willHaveAnyAggregateStream = eventCommandPairs.some(pair => {
25
+ const eventDef = messages.find(m => m.name === pair.eventType);
26
+ return states.some(state =>
27
+ findPrimitiveLinkingField(state.fields, eventDef?.fields || []) !== undefined
28
+ );
29
+ });
25
30
  -%>
26
31
  import {
27
32
  inMemoryReactor,
28
33
  type MessageHandlerResult,
29
- IllegalStateError,
30
34
  } from '@event-driven-io/emmett';
31
- import type { <%= pascalCase(eventType) %> } from '<%= eventImportBase %>/events';
35
+ <% for (const [importBase, types] of importGroups) { -%>
36
+ import type { <%= types.join(', ') %> } from '<%= importBase %>/events';
37
+ <% } -%>
32
38
  import type { ReactorContext } from '../../../shared';
33
39
 
34
40
  export const react = ({ eventStore, commandSender, database }: ReactorContext) =>
35
- inMemoryReactor<<%= pascalCase(eventType) %>>({
41
+ inMemoryReactor<<%= eventUnion %>>({
36
42
  processorId: '<%= toKebabCase(flowName) %>-<%= toKebabCase(slice.name) %>',
37
- canHandle: ['<%= eventType %>'],
43
+ canHandle: [<%= eventCommandPairs.map(p => `'${p.eventType}'`).join(', ') %>],
38
44
  connectionOptions: {
39
45
  database,
40
46
  },
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
- -%>
47
+ eachMessage: async (event): Promise<MessageHandlerResult> => {
49
48
  /**
50
49
  * ## IMPLEMENTATION INSTRUCTIONS ##
51
50
  *
@@ -57,7 +56,7 @@ const willHaveAggregateStream = states.some(state =>
57
56
  * NEVER hardcode values copied from test assertions.
58
57
  *
59
58
  * Preserve all import paths above — they are generated from the model.
60
- <% if (willHaveAggregateStream) { -%>
59
+ <% if (willHaveAnyAggregateStream) { -%>
61
60
  * Do NOT modify or remove aggregateStream calls — they load required state.
62
61
  <% } -%>
63
62
  *
@@ -69,31 +68,38 @@ const willHaveAggregateStream = states.some(state =>
69
68
  *
70
69
  * Add business logic (validation, conditional sends) as needed.
71
70
  */
72
- <%
73
- const commandFields = (commandDef?.fields || []);
74
- const eventFieldSet = new Set((eventDef?.fields || []).map(f => f.name));
75
- const stateFieldSources = {};
71
+ <% if (eventCommandPairs.length === 1) {
72
+ const pair = eventCommandPairs[0];
73
+ const eventDef = messages.find(m => m.name === pair.eventType);
74
+ const commandDef = messages.find(m => m.name === pair.commandType && m.type === 'command');
75
+ const commandFields = (commandDef?.fields || []);
76
+ const eventFieldSet = new Set((eventDef?.fields || []).map(f => f.name));
77
+ const stateFieldSources = {};
76
78
  -%>
77
79
  <% if (eventDef?.fields?.length) { %>
78
- // Event (<%= eventType %>) fields: <%= eventDef.fields.map(f => f.name + ': ' + (f.tsType || f.type)).join(', ') %>
80
+ // Event (<%= pair.eventType %>) fields: <%= eventDef.fields.map(f => f.name + ': ' + (f.tsType || f.type)).join(', ') %>
79
81
  <% } -%>
80
82
  <% if (commandDef?.fields?.length) { %>
81
- // Command (<%= commandType %>) fields: <%= commandDef.fields.map(f => f.name + ': ' + (f.tsType || f.type)).join(', ') %>
83
+ // Command (<%= pair.commandType %>) fields: <%= commandDef.fields.map(f => f.name + ': ' + (f.tsType || f.type)).join(', ') %>
82
84
  <% } -%>
83
85
  <% if (states.length > 0) {
84
- let hasAggregateStream = false;
85
- for (const state of states) {
86
- const linkingField = findPrimitiveLinkingField(state.fields, eventDef?.fields || []);
87
- if (linkingField) {
88
- hasAggregateStream = true;
89
- const varName = camelCase(state.type);
90
- for (const f of state.fields) {
91
- if (!stateFieldSources[f.name]) stateFieldSources[f.name] = varName;
92
- }
86
+ let hasAggregateStream = false;
87
+ for (const state of states) {
88
+ const linkingField = findPrimitiveLinkingField(state.fields, eventDef?.fields || []);
89
+ if (linkingField) {
90
+ hasAggregateStream = true;
91
+ const varName = camelCase(state.type);
92
+ for (const f of state.fields) {
93
+ if (!stateFieldSources[f.name]) stateFieldSources[f.name] = varName;
94
+ }
95
+ const stateUsedByCommand = commandFields.some(f =>
96
+ !eventFieldSet.has(f.name) && state.fields.some(sf => sf.name === f.name)
97
+ );
98
+ const varPrefix = stateUsedByCommand ? '' : '_';
93
99
  -%>
94
100
 
95
- const { state: <%= varName %> } = await eventStore.aggregateStream(
96
- '<%= state.type %>-' + event.data.<%= linkingField %>,
101
+ const { state: <%= varPrefix %><%= varName %> } = await eventStore.aggregateStream(
102
+ `<%= state.type %>-${event.data.<%= linkingField %>}`,
97
103
  {
98
104
  evolve: (currentState: Record<string, unknown>, evt: { type: string; data: Record<string, unknown> }) => ({ ...currentState, ...evt.data }),
99
105
  initialState: (): Record<string, unknown> => ({}),
@@ -101,35 +107,103 @@ const stateFieldSources = {};
101
107
  );
102
108
  // <%= state.type %> fields: <%= state.fields.map(f => f.name).join(', ') %>
103
109
 
104
- <% }
105
- }
106
- if (!hasAggregateStream) {
110
+ <% }
111
+ }
112
+ if (!hasAggregateStream) {
107
113
  -%>
108
114
 
109
- <% }
110
- } else {
115
+ <% }
116
+ } else {
111
117
  -%>
112
118
 
113
119
  <% } -%>
114
120
  await commandSender.send({
115
- type: '<%= commandType %>',
121
+ type: '<%= pair.commandType %>',
116
122
  kind: 'Command',
117
123
  data: {
118
124
  <% for (const field of commandFields) {
119
- const fieldName = field.name;
120
- if (eventFieldSet.has(fieldName)) {
125
+ const fieldName = field.name;
126
+ if (eventFieldSet.has(fieldName)) {
121
127
  -%>
122
128
  <%= fieldName %>: event.data.<%= fieldName %>,
123
- <% } else if (stateFieldSources[fieldName]) { -%>
129
+ <% } else if (stateFieldSources[fieldName]) { -%>
124
130
  <%= fieldName %>: <%= stateFieldSources[fieldName] %>.<%= fieldName %>,
125
- <% } else { -%>
131
+ <% } else { -%>
126
132
  <%= fieldName %>: undefined, // TODO: source unknown
127
- <% }
128
- }
133
+ <% }
134
+ }
129
135
  -%>
130
136
  },
131
137
  });
132
138
 
133
139
  return;
140
+ <% } else {
141
+ for (let pairIdx = 0; pairIdx < eventCommandPairs.length; pairIdx++) {
142
+ const pair = eventCommandPairs[pairIdx];
143
+ const eventDef = messages.find(m => m.name === pair.eventType);
144
+ const commandDef = messages.find(m => m.name === pair.commandType && m.type === 'command');
145
+ const commandFields = (commandDef?.fields || []);
146
+ const eventFieldSet = new Set((eventDef?.fields || []).map(f => f.name));
147
+ const stateFieldSources = {};
148
+ const condition = pairIdx === 0 ? 'if' : '} else if';
149
+ -%>
150
+ <%= condition %> (event.type === '<%= pair.eventType %>') {
151
+ <% if (eventDef?.fields?.length) { %>
152
+ // Event (<%= pair.eventType %>) fields: <%= eventDef.fields.map(f => f.name + ': ' + (f.tsType || f.type)).join(', ') %>
153
+ <% } -%>
154
+ <% if (commandDef?.fields?.length) { %>
155
+ // Command (<%= pair.commandType %>) fields: <%= commandDef.fields.map(f => f.name + ': ' + (f.tsType || f.type)).join(', ') %>
156
+ <% } -%>
157
+ <% if (states.length > 0) {
158
+ for (const state of states) {
159
+ const linkingField = findPrimitiveLinkingField(state.fields, eventDef?.fields || []);
160
+ if (linkingField) {
161
+ const varName = camelCase(state.type);
162
+ for (const f of state.fields) {
163
+ if (!stateFieldSources[f.name]) stateFieldSources[f.name] = varName;
164
+ }
165
+ const stateUsedByCommand = commandFields.some(f =>
166
+ !eventFieldSet.has(f.name) && state.fields.some(sf => sf.name === f.name)
167
+ );
168
+ const varPrefix = stateUsedByCommand ? '' : '_';
169
+ -%>
170
+
171
+ const { state: <%= varPrefix %><%= varName %> } = await eventStore.aggregateStream(
172
+ `<%= state.type %>-${event.data.<%= linkingField %>}`,
173
+ {
174
+ evolve: (currentState: Record<string, unknown>, evt: { type: string; data: Record<string, unknown> }) => ({ ...currentState, ...evt.data }),
175
+ initialState: (): Record<string, unknown> => ({}),
176
+ },
177
+ );
178
+ // <%= state.type %> fields: <%= state.fields.map(f => f.name).join(', ') %>
179
+
180
+ <% }
181
+ }
182
+ }
183
+ -%>
184
+ await commandSender.send({
185
+ type: '<%= pair.commandType %>',
186
+ kind: 'Command',
187
+ data: {
188
+ <% for (const field of commandFields) {
189
+ const fieldName = field.name;
190
+ if (eventFieldSet.has(fieldName)) {
191
+ -%>
192
+ <%= fieldName %>: event.data.<%= fieldName %>,
193
+ <% } else if (stateFieldSources[fieldName]) { -%>
194
+ <%= fieldName %>: <%= stateFieldSources[fieldName] %>.<%= fieldName %>,
195
+ <% } else { -%>
196
+ <%= fieldName %>: undefined, // TODO: source unknown
197
+ <% }
198
+ }
199
+ -%>
200
+ },
201
+ });
202
+ <% }
203
+ -%>
204
+ }
205
+
206
+ return;
207
+ <% } -%>
134
208
  },
135
209
  });