@auto-engineer/server-generator-apollo-emmett 1.155.0 → 1.156.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 (49) 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 +39 -0
  5. package/dist/src/codegen/extract/data-sink.d.ts +1 -0
  6. package/dist/src/codegen/extract/data-sink.d.ts.map +1 -1
  7. package/dist/src/codegen/extract/data-sink.js +3 -0
  8. package/dist/src/codegen/extract/data-sink.js.map +1 -1
  9. package/dist/src/codegen/extract/messages.d.ts +1 -0
  10. package/dist/src/codegen/extract/messages.d.ts.map +1 -1
  11. package/dist/src/codegen/extract/messages.js +1 -1
  12. package/dist/src/codegen/extract/messages.js.map +1 -1
  13. package/dist/src/codegen/extract/sibling-events.d.ts +8 -0
  14. package/dist/src/codegen/extract/sibling-events.d.ts.map +1 -0
  15. package/dist/src/codegen/extract/sibling-events.js +58 -0
  16. package/dist/src/codegen/extract/sibling-events.js.map +1 -0
  17. package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
  18. package/dist/src/codegen/scaffoldFromSchema.js +37 -2
  19. package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
  20. package/dist/src/codegen/templates/command/decide.specs.specs.ts +125 -0
  21. package/dist/src/codegen/templates/command/decide.specs.ts +258 -3
  22. package/dist/src/codegen/templates/command/decide.specs.ts.ejs +42 -14
  23. package/dist/src/codegen/templates/command/decide.ts.ejs +63 -16
  24. package/dist/src/codegen/templates/command/evolve.specs.ts +217 -39
  25. package/dist/src/codegen/templates/command/evolve.ts.ejs +7 -7
  26. package/dist/src/codegen/templates/command/handle.specs.ts +7 -4
  27. package/dist/src/codegen/templates/command/handle.ts.ejs +17 -5
  28. package/dist/src/codegen/templates/command/state.specs.ts +125 -0
  29. package/dist/src/codegen/templates/command/state.ts.ejs +10 -2
  30. package/dist/tsconfig.tsbuildinfo +1 -1
  31. package/ketchup-plan.md +3 -3
  32. package/package.json +4 -4
  33. package/src/codegen/extract/data-sink.specs.ts +23 -1
  34. package/src/codegen/extract/data-sink.ts +4 -0
  35. package/src/codegen/extract/messages.ts +1 -1
  36. package/src/codegen/extract/sibling-events.specs.ts +206 -0
  37. package/src/codegen/extract/sibling-events.ts +76 -0
  38. package/src/codegen/ketchup-plan.md +12 -0
  39. package/src/codegen/scaffoldFromSchema.ts +50 -0
  40. package/src/codegen/templates/command/decide.specs.specs.ts +125 -0
  41. package/src/codegen/templates/command/decide.specs.ts +258 -3
  42. package/src/codegen/templates/command/decide.specs.ts.ejs +42 -14
  43. package/src/codegen/templates/command/decide.ts.ejs +63 -16
  44. package/src/codegen/templates/command/evolve.specs.ts +217 -39
  45. package/src/codegen/templates/command/evolve.ts.ejs +7 -7
  46. package/src/codegen/templates/command/handle.specs.ts +7 -4
  47. package/src/codegen/templates/command/handle.ts.ejs +17 -5
  48. package/src/codegen/templates/command/state.specs.ts +125 -0
  49. package/src/codegen/templates/command/state.ts.ejs +10 -2
@@ -1,3 +1,24 @@
1
+ <%
2
+ const needsRandomUuidImport = (() => {
3
+ for (const cmdName of Object.keys(gwtMapping)) {
4
+ const cmdFieldSet = new Set((commandSchemasByName?.[cmdName]?.fields || []).map(f => f.name));
5
+ const scenarios = gwtMapping?.[cmdName] ?? [];
6
+ for (const v of (uuidVars || [])) {
7
+ if (cmdFieldSet.has(v)) continue;
8
+ for (const scenario of scenarios) {
9
+ for (const t of scenario.then.filter(t => 'eventRef' in t)) {
10
+ const eventMsg = (messages || []).find(m => m.name === t.eventRef && m.type === 'event');
11
+ if ((eventMsg?.fields || []).some(f => f.name === v)) return true;
12
+ }
13
+ }
14
+ }
15
+ }
16
+ return false;
17
+ })();
18
+ -%>
19
+ <% if (needsRandomUuidImport) { -%>
20
+ import { randomUUID } from 'node:crypto';
21
+ <% } -%>
1
22
  import {
2
23
  IllegalStateError<% if (usedErrors.includes('ValidationError')) { %>, ValidationError<% } %><% if (usedErrors.includes('NotFoundError')) { %>, NotFoundError<% } %>
3
24
  } from '@event-driven-io/emmett';
@@ -16,6 +37,19 @@ const integrationReturnSystem = integration?._withState?.origin?.systems?.[0];
16
37
  const integrationReturnImportSource = integrations?.find(i => i.name === integrationReturnSystem)?.source;
17
38
  const integrationReturnFields = messages?.find(m => m.name === integrationReturnType && m.type === 'state')?.fields ?? [];
18
39
 
40
+ const hasContextGenerated = (uuidVars || []).length > 0;
41
+ const hasContextIntegration = !!integrationReturnType;
42
+ const hasContext = hasContextGenerated || hasContextIntegration;
43
+ const contextTypeParts = [];
44
+ if (hasContextGenerated) {
45
+ const generatedFields = (uuidVars || []).map(v => `${v}: string`).join('; ');
46
+ contextTypeParts.push(`generated?: { ${generatedFields} }`);
47
+ }
48
+ if (hasContextIntegration) {
49
+ contextTypeParts.push(`${camelCase(integrationReturnType)}?: ${integrationReturnType}`);
50
+ }
51
+ const contextTypeStr = contextTypeParts.join('; ');
52
+
19
53
  function formatFieldDocLine(field) {
20
54
  const optional = field.required ? '' : '?';
21
55
  const name = `${field.name}${optional}`;
@@ -39,7 +73,7 @@ function formatFieldDocLine(field) {
39
73
 
40
74
  export const decide = (
41
75
  command: <%= Object.keys(gwtMapping).map(pascalCase).join(' | ') %>,
42
- _state: State<%= integrationReturnType ? `,\n ${camelCase(integrationReturnType)}?: ${integrationReturnType}` : '' %>
76
+ _state: State<%= hasContext ? `,\ncontext?: { ${contextTypeStr} }` : '' %>
43
77
  ): <%= uniqueEventTypes.length === 0
44
78
  ? 'never'
45
79
  : uniqueEventTypes.length === 1
@@ -49,11 +83,13 @@ switch (command.type) {
49
83
  <% for (const command of Object.keys(gwtMapping)) {
50
84
  const scenarios = gwtMapping?.[command] ?? [];
51
85
  const hasGivenEvents = scenarios.some(s =>
52
- (s.given || []).some(g => events.some(e => e.type === g.eventRef))
86
+ (s.given || []).some(g => allStreamEvents.some(e => e.type === g.eventRef))
53
87
  );
54
88
  const hasGivenStates = scenarios.some(s =>
55
- (s.given || []).some(g => !events.some(e => e.type === g.eventRef))
89
+ (s.given || []).some(g => !allStreamEvents.some(e => e.type === g.eventRef))
56
90
  );
91
+ const hasPrecedingSiblings = precedingSiblingEvents.length > 0;
92
+ const effectiveHasGivenEvents = hasGivenEvents || (hasGivenStates && hasPrecedingSiblings);
57
93
  const fallbackEvents = scenarios.flatMap(s => s.then.filter(t => 'eventRef' in t));
58
94
  const fallbackEventTypes = [...new Set(fallbackEvents.map(e => e.eventRef))];
59
95
  -%>
@@ -61,22 +97,22 @@ case '<%= command %>': {
61
97
  /**
62
98
  * ## IMPLEMENTATION INSTRUCTIONS ##
63
99
  *
64
- * This command <%= hasGivenEvents ? 'requires evaluating prior state to determine if it can proceed' : 'can directly emit one or more events based on the input' %>.
100
+ * This command <%= effectiveHasGivenEvents ? 'requires evaluating prior state to determine if it can proceed' : 'can directly emit one or more events based on the input' %>.
65
101
  *
66
102
  * You should:
67
103
  * - Validate the command input fields
68
- <% if (hasGivenEvents) { -%>
104
+ <% if (effectiveHasGivenEvents) { -%>
69
105
  * - Inspect the current domain `_state` to determine if the command is allowed
70
106
  <% } -%>
71
107
  * - NEVER use `as SomeType` type assertions — not `as any`, not `as EventType`, no casts at all. Use typed variable declarations.
72
- <% if (hasGivenEvents) { -%>
108
+ <% if (effectiveHasGivenEvents) { -%>
73
109
  * - If State is a discriminated union, narrow with the discriminant: `if (_state.status !== 'active') throw new IllegalStateError('...');`
74
110
  * After narrowing, access variant fields directly — TypeScript infers the correct type.
75
111
  <% } else if (hasGivenStates) { -%>
76
112
  * - The test uses given([]) with initialState(). Do not narrow or guard on _state — the command should succeed from initial state.
77
113
  <% } -%>
78
114
  <% if (integrationReturnType) { -%>
79
- * - Use `<%= camelCase(integrationReturnType) %>` (integration result) to enrich or filter the output
115
+ * - Use `context?.<%= camelCase(integrationReturnType) %>` (integration result) to enrich or filter the output
80
116
  <% } -%>
81
117
  * - If invalid, throw one of the following domain errors: `IllegalStateError`<% if (usedErrors.includes('ValidationError')) { %>, `ValidationError`<% } %><% if (usedErrors.includes('NotFoundError')) { %>, `NotFoundError`<% } %>
82
118
  * ⚠️ 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? }<% } %>
@@ -87,7 +123,7 @@ case '<%= command %>': {
87
123
  <% if (integrationReturnFields.length > 0) { -%>
88
124
  *
89
125
  * Integration result shape (<%= integrationReturnType %>):
90
- * <%= camelCase(integrationReturnType) %>?.data = {
126
+ * context?.<%= camelCase(integrationReturnType) %>?.data = {
91
127
  <%= integrationReturnFields.flatMap(formatFieldDocLine).join('\n') %>
92
128
  * }
93
129
  <% } -%>
@@ -123,28 +159,39 @@ for (const scenario of scenarios) {
123
159
  }
124
160
  }
125
161
  }
162
+ const uuidAssignments = (uuidVars || []).filter(v => nonCommandFields.some(f => f.name === v));
163
+ const filteredNonCommandFields = nonCommandFields.filter(f => !(uuidVars || []).includes(f.name));
126
164
  const eventResults = scenarios.flatMap(s => s.then.filter(t => 'eventRef' in t));
127
- const givenEventsForCmd = scenarios.flatMap(s =>
128
- (s.given || []).filter(g => events.some(e => e.type === g.eventRef))
165
+ let givenEventsForCmd = scenarios.flatMap(s =>
166
+ (s.given || []).filter(g => allStreamEvents.some(e => e.type === g.eventRef))
129
167
  );
168
+ if (givenEventsForCmd.length === 0 && hasGivenStates && hasPrecedingSiblings) {
169
+ givenEventsForCmd = precedingSiblingEvents.map(e => ({
170
+ eventRef: e.type,
171
+ exampleData: siblingExampleData[e.type] || {},
172
+ }));
173
+ }
130
174
  const { fields: derivedDateFieldNames } = findDerivedDateInfo(eventResults, cmdFieldSet, givenEventsForCmd);
131
175
  const derivedDateFields = new Set(derivedDateFieldNames);
132
176
  const keepFieldNamesSet = buildKeepFieldNames(eventResults, cmdFieldSet, derivedDateFieldNames, givenEventsForCmd);
133
177
  -%>
134
- <% if (nonCommandFields.length > 0) { -%>
178
+ <% for (const v of uuidAssignments) { -%>
179
+ const <%= v %> = context?.generated?.<%= v %> ?? randomUUID();
180
+ <% } -%>
181
+ <% if (filteredNonCommandFields.length > 0) { -%>
135
182
  // ⚠️ REQUIRED: Your return value MUST include ALL fields defined in the event type.
136
183
  // Tests use partial matching and may not check every field — passing tests does NOT mean all fields are present.
137
184
  // Do NOT use 'as <%= fallbackEventTypes[0] ? pascalCase(fallbackEventTypes[0]) : 'EventType' %>' to silence missing fields.
138
185
  //
139
186
  // Fields from command input → use ...command.data or command.data.<fieldName>
140
187
  // Fields NOT in command input → produce dynamically (never hardcode):
141
- <% for (const f of nonCommandFields) {
188
+ <% for (const f of filteredNonCommandFields) {
142
189
  const isDerivedDate = derivedDateFields.has(f.name);
143
190
  const isAssertedByTest = keepFieldNamesSet.has(f.name);
144
191
  -%>
145
192
  <% if (isDerivedDate) { -%>
146
193
  // <%= f.name %>: <%= f.type || f.tsType %> — derive from command.metadata?.now (convert Date to ISO string, e.g., .toISOString().substring(0, 10))
147
- <% } else if (hasGivenEvents) { -%>
194
+ <% } else if (effectiveHasGivenEvents) { -%>
148
195
  // <%= f.name %>: <%= f.type || f.tsType %> — derive from _state, generate at runtime (e.g., crypto.randomUUID()), or compute from command.data
149
196
  <% } else if (!isAssertedByTest) { -%>
150
197
  // <%= f.name %>: <%= f.type || f.tsType %> — not asserted by test; produce a valid runtime value (e.g., crypto.randomUUID() for IDs, '' for other strings)
@@ -152,14 +199,14 @@ const keepFieldNamesSet = buildKeepFieldNames(eventResults, cmdFieldSet, derived
152
199
  // <%= f.name %>: <%= f.type || f.tsType %> — generate at runtime (e.g., crypto.randomUUID()), or compute from command.data
153
200
  <% } -%>
154
201
  <% } -%>
155
- <% } else { -%>
202
+ <% } else if (uuidAssignments.length === 0) { -%>
156
203
  // All event fields come from command input — use ...command.data to pass them through.
157
204
  <% } -%>
158
205
 
159
206
  // IMPLEMENT: Use a typed variable to prevent type widening:
160
207
  // const result: <%= pascalCase(fallbackEventTypes[0] ?? 'TODO_EVENT_TYPE') %> = {
161
208
  // type: '<%= fallbackEventTypes[0] %>',
162
- // data: { ...command.data<%= nonCommandFields.length > 0 ? `, /* + produce: ${nonCommandFields.map(f => f.name).join(', ')} */` : '' %> },
209
+ // data: { ...command.data<%= uuidAssignments.length > 0 ? `, ${uuidAssignments.join(', ')}` : '' %><%= filteredNonCommandFields.length > 0 ? `, /* + produce: ${filteredNonCommandFields.map(f => f.name).join(', ')} */` : '' %> },
163
210
  // };
164
211
  // return result;
165
212
 
@@ -169,4 +216,4 @@ throw new IllegalStateError('Not yet implemented: ' + command.type);
169
216
  default:
170
217
  throw new IllegalStateError('Unexpected command type');
171
218
  }
172
- };
219
+ };
@@ -80,46 +80,224 @@ describe('evolve.ts.ejs', () => {
80
80
  const { plans } = await generateScaffoldFilePlans(spec.scenes, spec.messages, undefined, 'src/domain/narratives');
81
81
  const evolveFile = plans.find((p) => p.outputPath.endsWith('evolve.ts'));
82
82
 
83
- expect(evolveFile?.contents).toMatchInlineSnapshot(`
84
- "import type { State } from './state';
85
- import type { ListingCreated } from './events';
83
+ const contents = evolveFile?.contents ?? '';
84
+ expect(contents).toContain('import type { State }');
85
+ expect(contents).toContain('ListingCreated');
86
+ expect(contents).toContain('export const evolve');
87
+ expect(contents).toContain('default:');
88
+ expect(contents).toContain('return state;');
89
+ });
90
+
91
+ it('should include sibling events from shared stream in evolve', async () => {
92
+ const spec: SpecsSchema = {
93
+ variant: 'specs',
94
+ scenes: [
95
+ {
96
+ name: 'Gym session',
97
+ moments: [
98
+ {
99
+ type: 'command',
100
+ name: 'Start session',
101
+ client: { specs: [] },
102
+ server: {
103
+ description: '',
104
+ data: {
105
+ items: [
106
+ {
107
+ target: { type: 'Event', name: 'SessionStarted' },
108
+ destination: { type: 'stream', pattern: 'sessions-${id}' },
109
+ },
110
+ ],
111
+ },
112
+ specs: [
113
+ {
114
+ type: 'gherkin',
115
+ feature: 'Start session',
116
+ rules: [
117
+ {
118
+ name: 'Start session rule',
119
+ examples: [
120
+ {
121
+ name: 'Start session example',
122
+ steps: [
123
+ { keyword: 'When', text: 'StartSession', docString: { userId: 'u1' } },
124
+ {
125
+ keyword: 'Then',
126
+ text: 'SessionStarted',
127
+ docString: { id: 's1', userId: 'u1' },
128
+ },
129
+ ],
130
+ },
131
+ ],
132
+ },
133
+ ],
134
+ },
135
+ ],
136
+ },
137
+ },
138
+ {
139
+ type: 'command',
140
+ name: 'Add exercise',
141
+ client: { specs: [] },
142
+ server: {
143
+ description: '',
144
+ data: {
145
+ items: [
146
+ {
147
+ target: { type: 'Event', name: 'ExerciseAdded' },
148
+ destination: { type: 'stream', pattern: 'sessions-${sessionId}' },
149
+ },
150
+ ],
151
+ },
152
+ specs: [
153
+ {
154
+ type: 'gherkin',
155
+ feature: 'Add exercise',
156
+ rules: [
157
+ {
158
+ name: 'Add exercise rule',
159
+ examples: [
160
+ {
161
+ name: 'Add exercise example',
162
+ steps: [
163
+ {
164
+ keyword: 'Given',
165
+ text: 'ActiveSession',
166
+ docString: { status: 'active' },
167
+ },
168
+ {
169
+ keyword: 'When',
170
+ text: 'AddExercise',
171
+ docString: { sessionId: 's1', exercise: 'pushup' },
172
+ },
173
+ {
174
+ keyword: 'Then',
175
+ text: 'ExerciseAdded',
176
+ docString: { sessionId: 's1', exercise: 'pushup' },
177
+ },
178
+ ],
179
+ },
180
+ ],
181
+ },
182
+ ],
183
+ },
184
+ ],
185
+ },
186
+ },
187
+ {
188
+ type: 'command',
189
+ name: 'Complete session',
190
+ client: { specs: [] },
191
+ server: {
192
+ description: '',
193
+ data: {
194
+ items: [
195
+ {
196
+ target: { type: 'Event', name: 'SessionCompleted' },
197
+ destination: { type: 'stream', pattern: 'sessions-${sessionId}' },
198
+ },
199
+ ],
200
+ },
201
+ specs: [
202
+ {
203
+ type: 'gherkin',
204
+ feature: 'Complete session',
205
+ rules: [
206
+ {
207
+ name: 'Complete session rule',
208
+ examples: [
209
+ {
210
+ name: 'Complete session example',
211
+ steps: [
212
+ {
213
+ keyword: 'Given',
214
+ text: 'ActiveSession',
215
+ docString: { status: 'active' },
216
+ },
217
+ {
218
+ keyword: 'When',
219
+ text: 'CompleteSession',
220
+ docString: { sessionId: 's1' },
221
+ },
222
+ {
223
+ keyword: 'Then',
224
+ text: 'SessionCompleted',
225
+ docString: { sessionId: 's1', completedAt: '2024-01-15' },
226
+ },
227
+ ],
228
+ },
229
+ ],
230
+ },
231
+ ],
232
+ },
233
+ ],
234
+ },
235
+ },
236
+ ],
237
+ },
238
+ ],
239
+ messages: [
240
+ { type: 'command', name: 'StartSession', fields: [{ name: 'userId', type: 'string', required: true }] },
241
+ {
242
+ type: 'command',
243
+ name: 'AddExercise',
244
+ fields: [
245
+ { name: 'sessionId', type: 'string', required: true },
246
+ { name: 'exercise', type: 'string', required: true },
247
+ ],
248
+ },
249
+ { type: 'command', name: 'CompleteSession', fields: [{ name: 'sessionId', type: 'string', required: true }] },
250
+ {
251
+ type: 'event',
252
+ name: 'SessionStarted',
253
+ source: 'internal',
254
+ fields: [
255
+ { name: 'id', type: 'string', required: true },
256
+ { name: 'userId', type: 'string', required: true },
257
+ ],
258
+ },
259
+ {
260
+ type: 'event',
261
+ name: 'ExerciseAdded',
262
+ source: 'internal',
263
+ fields: [
264
+ { name: 'sessionId', type: 'string', required: true },
265
+ { name: 'exercise', type: 'string', required: true },
266
+ ],
267
+ },
268
+ {
269
+ type: 'event',
270
+ name: 'SessionCompleted',
271
+ source: 'internal',
272
+ fields: [
273
+ { name: 'sessionId', type: 'string', required: true },
274
+ { name: 'completedAt', type: 'string', required: true },
275
+ ],
276
+ },
277
+ { type: 'state', name: 'ActiveSession', fields: [{ name: 'status', type: 'string', required: true }] },
278
+ ],
279
+ };
280
+
281
+ const { plans } = await generateScaffoldFilePlans(spec.scenes, spec.messages, undefined, 'src/domain/narratives');
86
282
 
87
- /**
88
- * ## IMPLEMENTATION INSTRUCTIONS ##
89
- *
90
- * Evolve domain state in response to events.
91
- * Only track fields needed for future decisions in decide.ts.
92
- *
93
- * RULES:
94
- * - Return a new object literal for state transitions.
95
- * For no-op (event doesn’t affect state), return the existing \`state\`.
96
- * - If State is a discriminated union (check state.ts), EVERY return
97
- * MUST include the discriminant field as a string literal.
98
- * Example: \`return { status: ‘active’, field: event.data.field };\`
99
- * - NEVER spread ...event.data or ...state — list each field explicitly.
100
- *
101
- * VERIFY before finalizing:
102
- * [ ] Every return (except \`return state;\`) matches a variant from state.ts
103
- * [ ] Every return includes the discriminant field
104
- * [ ] No spread operators in return statements
105
- *
106
- * CONSTRAINTS:
107
- * - NEVER use \`as SomeType\` type assertions. Return properly typed object literals.
108
- * - Only reference event.data fields that exist on the event type (check imports above).
109
- * - Only return fields that exist in the State type (check state.ts). Do NOT invent new fields.
110
- */
283
+ const addExerciseEvolve = plans.find(
284
+ (p) => p.outputPath.includes('add-exercise') && p.outputPath.endsWith('evolve.ts'),
285
+ );
286
+ expect(addExerciseEvolve?.contents).toContain("import type { SessionStarted } from '../start-session/events';");
287
+ expect(addExerciseEvolve?.contents).toContain(
288
+ "import type { SessionCompleted } from '../complete-session/events';",
289
+ );
290
+ expect(addExerciseEvolve?.contents).toContain("case 'SessionStarted'");
291
+ expect(addExerciseEvolve?.contents).toContain("case 'ExerciseAdded'");
292
+ expect(addExerciseEvolve?.contents).toContain("case 'SessionCompleted'");
111
293
 
112
- export const evolve = (state: State, event: ListingCreated): State => {
113
- switch (event.type) {
114
- case 'ListingCreated': {
115
- // TODO: Return { status: 'variant', field1: ..., field2: ... } matching state.ts.
116
- return state;
117
- }
118
- default:
119
- return state;
120
- }
121
- };
122
- "
123
- `);
294
+ const completeSessionEvolve = plans.find(
295
+ (p) => p.outputPath.includes('complete-session') && p.outputPath.endsWith('evolve.ts'),
296
+ );
297
+ expect(completeSessionEvolve?.contents).toContain("import type { SessionStarted } from '../start-session/events';");
298
+ expect(completeSessionEvolve?.contents).toContain("import type { ExerciseAdded } from '../add-exercise/events';");
299
+ expect(completeSessionEvolve?.contents).toContain("case 'SessionStarted'");
300
+ expect(completeSessionEvolve?.contents).toContain("case 'ExerciseAdded'");
301
+ expect(completeSessionEvolve?.contents).toContain("case 'SessionCompleted'");
124
302
  });
125
303
  });
@@ -1,6 +1,6 @@
1
- <% if (events.length) { -%>
1
+ <% if (allStreamEvents.length) { -%>
2
2
  import type { State } from './state';
3
- <% for (const group of eventImportGroups) { -%>
3
+ <% for (const group of allStreamEventImportGroups) { -%>
4
4
  import type { <%= group.eventTypes.map(name => pascalCase(name)).join(', ') %> } from '<%= group.importPath %>';
5
5
  <% } -%>
6
6
 
@@ -12,10 +12,10 @@
12
12
  *
13
13
  * RULES:
14
14
  * - Return a new object literal for state transitions.
15
- * For no-op (event doesnt affect state), return the existing `state`.
15
+ * For no-op (event doesn't affect state), return the existing `state`.
16
16
  * - If State is a discriminated union (check state.ts), EVERY return
17
17
  * MUST include the discriminant field as a string literal.
18
- * Example: `return { status: active’, field: event.data.field };`
18
+ * Example: `return { status: 'active', field: event.data.field };`
19
19
  * - NEVER spread ...event.data or ...state — list each field explicitly.
20
20
  *
21
21
  * VERIFY before finalizing:
@@ -31,10 +31,10 @@
31
31
 
32
32
  export const evolve = (
33
33
  state: State,
34
- event: <%= events.map(e => pascalCase(e.type)).join(' | ') %>
34
+ event: <%= allStreamEvents.map(e => pascalCase(e.type)).join(' | ') %>
35
35
  ): State => {
36
36
  switch (event.type) {
37
- <% events.forEach(event => { -%>
37
+ <% allStreamEvents.forEach(event => { -%>
38
38
  case '<%= event.type %>': {
39
39
  // TODO: Return { status: 'variant', field1: ..., field2: ... } matching state.ts.
40
40
  return state;
@@ -46,4 +46,4 @@
46
46
  };
47
47
  <% } else { -%>
48
48
  // No events defined yet. Evolve logic will be generated once events exist.
49
- <% } -%>
49
+ <% } -%>
@@ -323,8 +323,10 @@ describe('generateScaffoldFilePlans', () => {
323
323
  // });
324
324
 
325
325
  await handler(eventStore, streamId, (state) =>
326
- // TODO: add products as a parameter to decide once implemented above
327
- decide(command, state /* products */),
326
+ // TODO: pass products in context once the integration call above is implemented
327
+ decide(command, state, {
328
+ /* products */
329
+ }),
328
330
  );
329
331
  return undefined;
330
332
  };
@@ -571,9 +573,10 @@ describe('generateScaffoldFilePlans', () => {
571
573
  });
572
574
 
573
575
  export const handle = async (eventStore: EventStore, command: LogWorkout): Promise<MessageHandlerResult> => {
574
- const streamId = \`workouts-\${randomUUID()}\`;
576
+ const workoutId = randomUUID();
577
+ const streamId = \`workouts-\${workoutId}\`;
575
578
 
576
- await handler(eventStore, streamId, (state) => decide(command, state));
579
+ await handler(eventStore, streamId, (state) => decide(command, state, { generated: { workoutId } }));
577
580
  return undefined;
578
581
  };
579
582
  "
@@ -65,7 +65,6 @@ const allCmdFieldSets = commands.map(c => new Set((c.fields ?? []).map(f => f.na
65
65
  const needsStreamGuard = streamVars.length > 0 && !allCmdFieldSets.every(fs => streamVars.every(v => fs.has(v)));
66
66
  const varInAnyCommand = (v) => allCmdFieldSets.some(fs => fs.has(v));
67
67
  const guardVars = needsStreamGuard ? streamVars.filter(v => varInAnyCommand(v)) : [];
68
- const uuidVars = needsStreamGuard ? streamVars.filter(v => !varInAnyCommand(v)) : [];
69
68
  %>
70
69
 
71
70
  <% if (uuidVars.length > 0) { -%>
@@ -102,6 +101,9 @@ eventStore: EventStore,
102
101
  command: <%= commands.map(c => pascalCase(c.type)).join(' | ') %>
103
102
  ): Promise<MessageHandlerResult> => {
104
103
  <% if (stream?.pattern?.includes('${')) { -%>
104
+ <% for (const v of uuidVars) { -%>
105
+ const <%= v %> = randomUUID();
106
+ <% } -%>
105
107
  <% if (!needsStreamGuard) { -%>
106
108
  const streamId = `<%= stream.pattern.replace(/\$\{([^}]+)\}/g, (_, key) => `\${command.data.${key}}`) %>`;
107
109
  <% } else { -%>
@@ -115,7 +117,7 @@ command: <%= commands.map(c => pascalCase(c.type)).join(' | ') %>
115
117
  <% } -%>
116
118
  const streamId = `<%= stream.pattern.replace(/\$\{([^}]+)\}/g, (_, key) => {
117
119
  if (varInAnyCommand(key)) return '${commandData.' + key + '}';
118
- return '${randomUUID()}';
120
+ return '${' + key + '}';
119
121
  }) %>`;
120
122
  <% } -%>
121
123
  <% } else { -%>
@@ -126,10 +128,20 @@ command: <%= commands.map(c => pascalCase(c.type)).join(' | ') %>
126
128
  <%= call %>
127
129
 
128
130
  <% }) -%>
131
+ <%
132
+ const hasGenerated = uuidVars.length > 0;
133
+ const hasIntegration = needsReturnValue;
134
+ const hasContext = hasGenerated || hasIntegration;
135
+ const ctxParts = [];
136
+ if (hasGenerated) ctxParts.push(`generated: { ${uuidVars.join(', ')} }`);
137
+ if (hasIntegration) ctxParts.push(`/* ${resultVarName} */`);
138
+ const contextStr = hasContext ? `, { ${ctxParts.join(', ')} }` : '';
139
+ -%>
129
140
  await handler(eventStore, streamId, (state) =>
130
- <%- needsReturnValue
131
- ? ` // TODO: add ${resultVarName} as a parameter to decide once implemented above\n decide(command, state, /* ${resultVarName} */)`
132
- : ` decide(command, state)` %>
141
+ <% if (hasIntegration) { -%>
142
+ // TODO: pass <%= resultVarName %> in context once the integration call above is implemented
143
+ <% } -%>
144
+ decide(command, state<%= contextStr %>)
133
145
  );
134
146
  return undefined;
135
147
  };