@auto-engineer/server-generator-apollo-emmett 1.104.0 → 1.105.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 (41) 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 +66 -0
  5. package/dist/src/codegen/scaffoldFromSchema.d.ts +10 -0
  6. package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
  7. package/dist/src/codegen/scaffoldFromSchema.js +55 -20
  8. package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
  9. package/dist/src/codegen/templates/command/decide.specs.ts +16 -4
  10. package/dist/src/codegen/templates/command/decide.ts.ejs +4 -1
  11. package/dist/src/codegen/templates/command/evolve.specs.ts +16 -15
  12. package/dist/src/codegen/templates/command/evolve.ts.ejs +16 -15
  13. package/dist/src/codegen/templates/command/handle.specs.ts +146 -0
  14. package/dist/src/codegen/templates/command/handle.ts.ejs +21 -1
  15. package/dist/src/codegen/templates/query/projection.specs.ts +4 -1
  16. package/dist/src/codegen/templates/query/projection.ts.ejs +7 -1
  17. package/dist/src/codegen/templates/react/react.specs.specs.ts +0 -32
  18. package/dist/src/codegen/templates/react/react.ts.specs.ts +0 -49
  19. package/dist/src/commands/generate-server.d.ts +4 -0
  20. package/dist/src/commands/generate-server.d.ts.map +1 -1
  21. package/dist/src/commands/generate-server.js +37 -3
  22. package/dist/src/commands/generate-server.js.map +1 -1
  23. package/dist/tsconfig.tsbuildinfo +1 -1
  24. package/ketchup-plan.md +2 -0
  25. package/package.json +4 -4
  26. package/src/codegen/formatTsValue.specs.ts +12 -0
  27. package/src/codegen/formatTsValueSimple.specs.ts +1 -1
  28. package/src/codegen/scaffoldErrors.specs.ts +40 -0
  29. package/src/codegen/scaffoldFromSchema.ts +70 -31
  30. package/src/codegen/templates/command/decide.specs.ts +16 -4
  31. package/src/codegen/templates/command/decide.ts.ejs +4 -1
  32. package/src/codegen/templates/command/evolve.specs.ts +16 -15
  33. package/src/codegen/templates/command/evolve.ts.ejs +16 -15
  34. package/src/codegen/templates/command/handle.specs.ts +146 -0
  35. package/src/codegen/templates/command/handle.ts.ejs +21 -1
  36. package/src/codegen/templates/query/projection.specs.ts +4 -1
  37. package/src/codegen/templates/query/projection.ts.ejs +7 -1
  38. package/src/codegen/templates/react/react.specs.specs.ts +0 -32
  39. package/src/codegen/templates/react/react.ts.specs.ts +0 -49
  40. package/src/commands/generate-server.specs.ts +71 -0
  41. package/src/commands/generate-server.ts +45 -2
package/ketchup-plan.md CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  ## DONE
8
8
 
9
+ - [x] Burst 24: Fix handle.ts multi-command stream pattern
10
+ - [x] Burst 23: Fix decide default case + add discriminated union narrowing guidance (a24a654f)
9
11
  - [x] Burst 22: Add null-document guidance to projection template (0d1d2007)
10
12
  - [x] Burst 21: Add discriminated union guidance to evolve template (7abc038c)
11
13
  - [x] Burst 20: Fill missing inline object fields with type defaults (b39bfd1b)
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/narrative": "1.104.0",
36
- "@auto-engineer/message-bus": "1.104.0"
35
+ "@auto-engineer/narrative": "1.105.0",
36
+ "@auto-engineer/message-bus": "1.105.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.104.0"
47
+ "@auto-engineer/cli": "1.105.0"
48
48
  },
49
- "version": "1.104.0",
49
+ "version": "1.105.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",
@@ -22,6 +22,18 @@ describe('formatTsValue', () => {
22
22
  it('returns null for null value', () => {
23
23
  expect(formatTsValue(null, 'string')).toBe('null');
24
24
  });
25
+
26
+ it('returns 0 for non-numeric string with number type', () => {
27
+ expect(formatTsValue('null', 'number')).toBe('0');
28
+ });
29
+
30
+ it('returns 0 for empty string with number type', () => {
31
+ expect(formatTsValue('', 'number')).toBe('0');
32
+ });
33
+
34
+ it('returns 0 for boolean with number type', () => {
35
+ expect(formatTsValue(true, 'number')).toBe('0');
36
+ });
25
37
  });
26
38
 
27
39
  describe('simple arrays', () => {
@@ -27,7 +27,7 @@ describe('formatTsValueSimple', () => {
27
27
  });
28
28
 
29
29
  it('should return number string for string value when tsType is number', () => {
30
- expect(formatTsValueSimple('5.00', 'number')).toBe('5.00');
30
+ expect(formatTsValueSimple('5.00', 'number')).toBe('5');
31
31
  });
32
32
 
33
33
  it('should return number string for number value when tsType is number', () => {
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ScaffoldError, TemplateRenderError } from './scaffoldFromSchema';
3
+
4
+ describe('TemplateRenderError', () => {
5
+ it('captures template file and chains cause', () => {
6
+ const original = new TypeError('Cannot read properties of undefined');
7
+ const error = new TemplateRenderError('decide.ts.ejs', original);
8
+
9
+ expect(error).toEqual(
10
+ expect.objectContaining({
11
+ name: 'TemplateRenderError',
12
+ message: 'Template render failed: decide.ts.ejs',
13
+ templateFile: 'decide.ts.ejs',
14
+ cause: original,
15
+ }),
16
+ );
17
+ expect(error).toBeInstanceOf(Error);
18
+ expect(error).toBeInstanceOf(TemplateRenderError);
19
+ });
20
+ });
21
+
22
+ describe('ScaffoldError', () => {
23
+ it('captures flow/slice/type and chains cause', () => {
24
+ const templateError = new TemplateRenderError('evolve.ts.ejs', new Error('bad data'));
25
+ const error = new ScaffoldError('Checkout', 'PlaceOrder', 'command', templateError);
26
+
27
+ expect(error).toEqual(
28
+ expect.objectContaining({
29
+ name: 'ScaffoldError',
30
+ message: 'Scaffold failed for Checkout/PlaceOrder (command)',
31
+ flowName: 'Checkout',
32
+ sliceName: 'PlaceOrder',
33
+ sliceType: 'command',
34
+ cause: templateError,
35
+ }),
36
+ );
37
+ expect(error).toBeInstanceOf(Error);
38
+ expect(error).toBeInstanceOf(ScaffoldError);
39
+ });
40
+ });
@@ -47,6 +47,30 @@ import { normalizeSliceForTemplate } from './extract/slice-normalizer';
47
47
  import { extractGwtSpecsFromSlice, type GwtResult } from './extract/step-converter';
48
48
  import type { GwtCondition, Message, MessageDefinition } from './types';
49
49
 
50
+ export class TemplateRenderError extends Error {
51
+ readonly templateFile: string;
52
+
53
+ constructor(templateFile: string, cause: unknown) {
54
+ super(`Template render failed: ${templateFile}`, { cause });
55
+ this.name = 'TemplateRenderError';
56
+ this.templateFile = templateFile;
57
+ }
58
+ }
59
+
60
+ export class ScaffoldError extends Error {
61
+ readonly flowName: string;
62
+ readonly sliceName: string;
63
+ readonly sliceType: string;
64
+
65
+ constructor(flowName: string, sliceName: string, sliceType: string, cause: unknown) {
66
+ super(`Scaffold failed for ${flowName}/${sliceName} (${sliceType})`, { cause });
67
+ this.name = 'ScaffoldError';
68
+ this.flowName = flowName;
69
+ this.sliceName = sliceName;
70
+ this.sliceType = sliceType;
71
+ }
72
+ }
73
+
50
74
  const defaultFilesByType: Record<string, string[]> = {
51
75
  command: [
52
76
  'commands.ts.ejs',
@@ -419,7 +443,14 @@ export function formatTsValueSimple(value: unknown, tsType: string): string {
419
443
  return JSON.stringify(typeof value === 'string' ? value : String(value));
420
444
  }
421
445
  if (tsType === 'number') {
422
- return String(value);
446
+ if (typeof value === 'number') return String(value);
447
+ if (typeof value === 'string') {
448
+ const s = value.trim();
449
+ if (s === '') return '0';
450
+ const num = Number(s);
451
+ return Number.isNaN(num) ? '0' : String(num);
452
+ }
453
+ return '0';
423
454
  }
424
455
  if (tsType === 'boolean') {
425
456
  return value === true || value === 'true' ? 'true' : 'false';
@@ -520,23 +551,27 @@ async function generateFileForTemplate(
520
551
  debugFiles(' Template path: %s', templatePath);
521
552
  debugFiles(' Output path: %s', outputPath);
522
553
 
523
- const contents = await renderTemplate(templatePath, templateData, unionToEnumName);
524
- debugFiles(' Rendered content size: %d bytes', contents.length);
525
-
526
- debugFiles(' Formatting with Prettier...');
527
- const formattedContents = await prettier.format(contents, {
528
- parser: 'typescript',
529
- filepath: outputPath,
530
- singleQuote: true,
531
- trailingComma: 'all',
532
- printWidth: 120,
533
- tabWidth: 2,
534
- });
535
- debugFiles(' Formatted content size: %d bytes', formattedContents.length);
536
-
537
- const plan = { outputPath, contents: formattedContents };
538
- debugFiles(' File plan created for: %s', fileName);
539
- return plan;
554
+ try {
555
+ const contents = await renderTemplate(templatePath, templateData, unionToEnumName);
556
+ debugFiles(' Rendered content size: %d bytes', contents.length);
557
+
558
+ debugFiles(' Formatting with Prettier...');
559
+ const formattedContents = await prettier.format(contents, {
560
+ parser: 'typescript',
561
+ filepath: outputPath,
562
+ singleQuote: true,
563
+ trailingComma: 'all',
564
+ printWidth: 120,
565
+ tabWidth: 2,
566
+ });
567
+ debugFiles(' Formatted content size: %d bytes', formattedContents.length);
568
+
569
+ const plan = { outputPath, contents: formattedContents };
570
+ debugFiles(' File plan created for: %s', fileName);
571
+ return plan;
572
+ } catch (error) {
573
+ throw new TemplateRenderError(templateFile, error);
574
+ }
540
575
  }
541
576
 
542
577
  export function resolveReferencedMessageTypes(
@@ -918,19 +953,23 @@ export async function generateScaffoldFilePlans(
918
953
  debugFlow(' Processing slice: %s (type: %s)', slice.name, slice.type);
919
954
  const sliceDir = ensureDirPath(flowDir, toKebabCase(slice.name));
920
955
  debugFlow(' Slice directory: %s', sliceDir);
921
- const { plans, duplicateCommands } = await generateFilesForSlice(
922
- slice,
923
- flow,
924
- sliceDir,
925
- sanitizedMessages,
926
- flows,
927
- unionToEnumName,
928
- integrations,
929
- registeredCommands,
930
- );
931
- debugFlow(' Generated %d plans for slice', plans.length);
932
- allPlans.push(...plans);
933
- allDuplicateCommands.push(...duplicateCommands);
956
+ try {
957
+ const { plans, duplicateCommands } = await generateFilesForSlice(
958
+ slice,
959
+ flow,
960
+ sliceDir,
961
+ sanitizedMessages,
962
+ flows,
963
+ unionToEnumName,
964
+ integrations,
965
+ registeredCommands,
966
+ );
967
+ debugFlow(' Generated %d plans for slice', plans.length);
968
+ allPlans.push(...plans);
969
+ allDuplicateCommands.push(...duplicateCommands);
970
+ } catch (error) {
971
+ throw new ScaffoldError(flow.name, slice.name, slice.type, error);
972
+ }
934
973
  }
935
974
  debugFlow(' Completed flow: %s', flow.name);
936
975
  }
@@ -97,6 +97,9 @@ describe('decide.ts.ejs', () => {
97
97
  * You should:
98
98
  * - Validate the command input fields
99
99
  * - Inspect the current domain \`_state\` to determine if the command is allowed
100
+ * - If State is a discriminated union, NEVER use \`as any\` to bypass type checking.
101
+ * Narrow with the discriminant: \`if (_state.status !== 'active') throw new IllegalStateError('...');\`
102
+ * After narrowing, access variant fields directly — TypeScript infers the correct type.
100
103
  * - If invalid, throw one of the following domain errors: \`IllegalStateError\`
101
104
  * ⚠️ Error constructors: IllegalStateError takes a string message
102
105
  * - If valid, return one or more events with the correct structure
@@ -117,7 +120,7 @@ describe('decide.ts.ejs', () => {
117
120
  throw new IllegalStateError('Not yet implemented: ' + command.type);
118
121
  }
119
122
  default:
120
- throw new IllegalStateError(\`Unexpected command type: \${String(command.type)}\`);
123
+ throw new IllegalStateError('Unexpected command type');
121
124
  }
122
125
  };
123
126
  "
@@ -232,6 +235,9 @@ describe('decide.ts.ejs', () => {
232
235
  * You should:
233
236
  * - Validate the command input fields
234
237
  * - Inspect the current domain \`_state\` to determine if the command is allowed
238
+ * - If State is a discriminated union, NEVER use \`as any\` to bypass type checking.
239
+ * Narrow with the discriminant: \`if (_state.status !== 'active') throw new IllegalStateError('...');\`
240
+ * After narrowing, access variant fields directly — TypeScript infers the correct type.
235
241
  * - If invalid, throw one of the following domain errors: \`IllegalStateError\`
236
242
  * ⚠️ Error constructors: IllegalStateError takes a string message
237
243
  * - If valid, return one or more events with the correct structure
@@ -258,7 +264,7 @@ describe('decide.ts.ejs', () => {
258
264
  throw new IllegalStateError('Not yet implemented: ' + command.type);
259
265
  }
260
266
  default:
261
- throw new IllegalStateError(\`Unexpected command type: \${String(command.type)}\`);
267
+ throw new IllegalStateError('Unexpected command type');
262
268
  }
263
269
  };
264
270
  "
@@ -393,6 +399,9 @@ describe('decide.ts.ejs', () => {
393
399
  * You should:
394
400
  * - Validate the command input fields
395
401
  * - Inspect the current domain \`_state\` to determine if the command is allowed
402
+ * - If State is a discriminated union, NEVER use \`as any\` to bypass type checking.
403
+ * Narrow with the discriminant: \`if (_state.status !== 'active') throw new IllegalStateError('...');\`
404
+ * After narrowing, access variant fields directly — TypeScript infers the correct type.
396
405
  * - If invalid, throw one of the following domain errors: \`IllegalStateError\`, \`ValidationError\`
397
406
  * ⚠️ Error constructors: IllegalStateError takes a string message, ValidationError takes a string message
398
407
  * - If valid, return one or more events with the correct structure
@@ -417,7 +426,7 @@ describe('decide.ts.ejs', () => {
417
426
  throw new IllegalStateError('Not yet implemented: ' + command.type);
418
427
  }
419
428
  default:
420
- throw new IllegalStateError(\`Unexpected command type: \${String(command.type)}\`);
429
+ throw new IllegalStateError('Unexpected command type');
421
430
  }
422
431
  };
423
432
  "
@@ -592,6 +601,9 @@ describe('decide.ts.ejs', () => {
592
601
  * You should:
593
602
  * - Validate the command input fields
594
603
  * - Inspect the current domain \`_state\` to determine if the command is allowed
604
+ * - If State is a discriminated union, NEVER use \`as any\` to bypass type checking.
605
+ * Narrow with the discriminant: \`if (_state.status !== 'active') throw new IllegalStateError('...');\`
606
+ * After narrowing, access variant fields directly — TypeScript infers the correct type.
595
607
  * - Use \`products\` (integration result) to enrich or filter the output
596
608
  * - If invalid, throw one of the following domain errors: \`IllegalStateError\`
597
609
  * ⚠️ Error constructors: IllegalStateError takes a string message
@@ -627,7 +639,7 @@ describe('decide.ts.ejs', () => {
627
639
  throw new IllegalStateError('Not yet implemented: ' + command.type);
628
640
  }
629
641
  default:
630
- throw new IllegalStateError(\`Unexpected command type: \${String(command.type)}\`);
642
+ throw new IllegalStateError('Unexpected command type');
631
643
  }
632
644
  };
633
645
  "
@@ -61,6 +61,9 @@ case '<%= command %>': {
61
61
  * You should:
62
62
  * - Validate the command input fields
63
63
  * - Inspect the current domain `_state` to determine if the command is allowed
64
+ * - If State is a discriminated union, NEVER use `as any` to bypass type checking.
65
+ * Narrow with the discriminant: `if (_state.status !== 'active') throw new IllegalStateError('...');`
66
+ * After narrowing, access variant fields directly — TypeScript infers the correct type.
64
67
  <% if (integrationReturnType) { -%>
65
68
  * - Use `<%= camelCase(integrationReturnType) %>` (integration result) to enrich or filter the output
66
69
  <% } -%>
@@ -132,6 +135,6 @@ throw new IllegalStateError('Not yet implemented: ' + command.type);
132
135
  }
133
136
  <% } -%>
134
137
  default:
135
- throw new IllegalStateError(`Unexpected command type: ${String(command.type)}`);
138
+ throw new IllegalStateError('Unexpected command type');
136
139
  }
137
140
  };
@@ -87,27 +87,28 @@ describe('evolve.ts.ejs', () => {
87
87
  /**
88
88
  * ## IMPLEMENTATION INSTRUCTIONS ##
89
89
  *
90
- * This function defines how the domain state evolves in response to events.
90
+ * Evolve domain state in response to events.
91
+ * Only track fields needed for future decisions in decide.ts.
91
92
  *
92
- * Guidelines:
93
- * - Apply only the **minimal** necessary changes for future decisions in \`decide.ts\`.
94
- * - Ignore any event fields not required for decision-making logic.
95
- * - If the event doesn’t change decision-relevant state, return the existing \`state\`.
96
- * - Prefer immutability: always return a **new state object**.
97
- * - Avoid spreading all of \`event.data\` unless all fields are relevant.
98
- * - If State is a discriminated union (e.g., NotInitialized | Initialized),
99
- * always include the discriminant field (e.g., \`status\`) in return values.
100
- * Do NOT spread \`...state\` and add variant-specific fields — construct a complete object:
101
- * \`return { status: 'initialized', field: event.data.field };\`
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
102
105
  */
103
106
 
104
107
  export const evolve = (state: State, event: ListingCreated): State => {
105
108
  switch (event.type) {
106
109
  case 'ListingCreated': {
107
- // TODO: Update state based on ListingCreated
108
- return {
109
- ...state,
110
- };
110
+ // TODO: Return { status: 'variant', field1: ..., field2: ... } matching state.ts.
111
+ return state;
111
112
  }
112
113
  default:
113
114
  return state;
@@ -7,18 +7,21 @@
7
7
  /**
8
8
  * ## IMPLEMENTATION INSTRUCTIONS ##
9
9
  *
10
- * This function defines how the domain state evolves in response to events.
10
+ * Evolve domain state in response to events.
11
+ * Only track fields needed for future decisions in decide.ts.
11
12
  *
12
- * Guidelines:
13
- * - Apply only the **minimal** necessary changes for future decisions in `decide.ts`.
14
- * - Ignore any event fields not required for decision-making logic.
15
- * - If the event doesn’t change decision-relevant state, return the existing `state`.
16
- * - Prefer immutability: always return a **new state object**.
17
- * - Avoid spreading all of `event.data` unless all fields are relevant.
18
- * - If State is a discriminated union (e.g., NotInitialized | Initialized),
19
- * always include the discriminant field (e.g., `status`) in return values.
20
- * Do NOT spread `...state` and add variant-specific fields — construct a complete object:
21
- * `return { status: 'initialized', field: event.data.field };`
13
+ * RULES:
14
+ * - Return a new object literal for state transitions.
15
+ * For no-op (event doesn’t affect state), return the existing `state`.
16
+ * - If State is a discriminated union (check state.ts), EVERY return
17
+ * MUST include the discriminant field as a string literal.
18
+ * Example: `return { status: ‘active’, field: event.data.field };`
19
+ * - NEVER spread ...event.data or ...state list each field explicitly.
20
+ *
21
+ * VERIFY before finalizing:
22
+ * [ ] Every return (except `return state;`) matches a variant from state.ts
23
+ * [ ] Every return includes the discriminant field
24
+ * [ ] No spread operators in return statements
22
25
  */
23
26
 
24
27
  export const evolve = (
@@ -28,10 +31,8 @@
28
31
  switch (event.type) {
29
32
  <% events.forEach(event => { -%>
30
33
  case '<%= event.type %>': {
31
- // TODO: Update state based on <%= event.type %>
32
- return {
33
- ...state
34
- };
34
+ // TODO: Return { status: 'variant', field1: ..., field2: ... } matching state.ts.
35
+ return state;
35
36
  }
36
37
  <% }); -%>
37
38
  default:
@@ -331,4 +331,150 @@ describe('generateScaffoldFilePlans', () => {
331
331
  "
332
332
  `);
333
333
  });
334
+ it('should generate stream guard when not all commands share stream pattern fields', async () => {
335
+ const spec: SpecsSchema = {
336
+ variant: 'specs',
337
+ narratives: [
338
+ {
339
+ name: 'Member tracks workouts',
340
+ slices: [
341
+ {
342
+ type: 'command',
343
+ name: 'Track workouts',
344
+ stream: 'workouts-${memberId}',
345
+ client: {
346
+ specs: [],
347
+ },
348
+ server: {
349
+ description: 'test',
350
+ specs: [
351
+ {
352
+ type: 'gherkin',
353
+ feature: 'Track workouts',
354
+ rules: [
355
+ {
356
+ name: 'Should log workout',
357
+ examples: [
358
+ {
359
+ name: 'Member logs a workout',
360
+ steps: [
361
+ {
362
+ keyword: 'When',
363
+ text: 'LogWorkout',
364
+ docString: { memberId: 'm1', exercise: 'squat' },
365
+ },
366
+ {
367
+ keyword: 'Then',
368
+ text: 'WorkoutLogged',
369
+ docString: { memberId: 'm1', exercise: 'squat' },
370
+ },
371
+ ],
372
+ },
373
+ ],
374
+ },
375
+ {
376
+ name: 'Should calculate points',
377
+ examples: [
378
+ {
379
+ name: 'Points are calculated',
380
+ steps: [
381
+ {
382
+ keyword: 'When',
383
+ text: 'CalculatePoints',
384
+ docString: { season: 'winter' },
385
+ },
386
+ {
387
+ keyword: 'Then',
388
+ text: 'PointsCalculated',
389
+ docString: { season: 'winter', points: 10 },
390
+ },
391
+ ],
392
+ },
393
+ ],
394
+ },
395
+ ],
396
+ },
397
+ ],
398
+ data: {
399
+ items: [
400
+ {
401
+ target: { type: 'Event', name: 'WorkoutLogged' },
402
+ destination: {
403
+ type: 'stream',
404
+ pattern: 'workouts-${memberId}',
405
+ },
406
+ },
407
+ ],
408
+ },
409
+ },
410
+ },
411
+ ],
412
+ },
413
+ ],
414
+ messages: [
415
+ {
416
+ type: 'command',
417
+ name: 'LogWorkout',
418
+ fields: [
419
+ { name: 'memberId', type: 'string', required: true },
420
+ { name: 'exercise', type: 'string', required: true },
421
+ ],
422
+ },
423
+ {
424
+ type: 'command',
425
+ name: 'CalculatePoints',
426
+ fields: [{ name: 'season', type: 'string', required: true }],
427
+ },
428
+ {
429
+ type: 'event',
430
+ name: 'WorkoutLogged',
431
+ source: 'internal',
432
+ fields: [
433
+ { name: 'memberId', type: 'string', required: true },
434
+ { name: 'exercise', type: 'string', required: true },
435
+ ],
436
+ },
437
+ {
438
+ type: 'event',
439
+ name: 'PointsCalculated',
440
+ source: 'internal',
441
+ fields: [
442
+ { name: 'season', type: 'string', required: true },
443
+ { name: 'points', type: 'number', required: true },
444
+ ],
445
+ },
446
+ ],
447
+ };
448
+
449
+ const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
450
+ const handleFile = plans.find((p) => p.outputPath.endsWith('handle.ts'));
451
+
452
+ expect(handleFile?.contents).toMatchInlineSnapshot(`
453
+ "import { CommandHandler, type EventStore, type MessageHandlerResult } from '@event-driven-io/emmett';
454
+ import { evolve } from './evolve';
455
+ import { initialState } from './state';
456
+ import { decide } from './decide';
457
+ import type { LogWorkout, CalculatePoints } from './commands';
458
+
459
+ const handler = CommandHandler({
460
+ evolve,
461
+ initialState,
462
+ });
463
+
464
+ export const handle = async (
465
+ eventStore: EventStore,
466
+ command: LogWorkout | CalculatePoints,
467
+ ): Promise<MessageHandlerResult> => {
468
+ const commandData = command.data;
469
+ if (!('memberId' in commandData)) {
470
+ throw new Error('Cannot determine stream: "memberId" not in command data');
471
+ }
472
+ const streamId = \`workouts-\${commandData.memberId}\`;
473
+
474
+ await handler(eventStore, streamId, (state) => decide(command, state));
475
+ return undefined;
476
+ };
477
+ "
478
+ `);
479
+ });
334
480
  });
@@ -53,6 +53,16 @@ const integrationCalls = integrationData.map((d, i) => {
53
53
 
54
54
  const resultVarName = integrationCalls.find(call => !!call.varName)?.varName;
55
55
  const needsReturnValue = typeof resultVarName === 'string';
56
+
57
+ const streamVars = [];
58
+ const streamPatternStr = stream?.pattern ?? '';
59
+ const svRegex = /\$\{([^}]+)\}/g;
60
+ let svMatch;
61
+ while ((svMatch = svRegex.exec(streamPatternStr)) !== null) {
62
+ streamVars.push(svMatch[1]);
63
+ }
64
+ const allCmdFieldSets = commands.map(c => new Set((c.fields ?? []).map(f => f.name)));
65
+ const needsStreamGuard = streamVars.length > 0 && !allCmdFieldSets.every(fs => streamVars.every(v => fs.has(v)));
56
66
  %>
57
67
 
58
68
  <% integrationSideEffectImports.forEach((importSource) => { %>
@@ -86,7 +96,17 @@ eventStore: EventStore,
86
96
  command: <%= commands.map(c => pascalCase(c.type)).join(' | ') %>
87
97
  ): Promise<MessageHandlerResult> => {
88
98
  <% if (stream?.pattern?.includes('${')) { -%>
89
- const streamId = `<%= stream.pattern.replace(/\$\{([^}]+)\}/g, (_, key) => `\${command.data.${key}}`) %>`;
99
+ <% if (needsStreamGuard) { -%>
100
+ const commandData = command.data;
101
+ <% for (const v of streamVars) { -%>
102
+ if (!('<%= v %>' in commandData)) {
103
+ throw new Error('Cannot determine stream: "<%= v %>" not in command data');
104
+ }
105
+ <% } -%>
106
+ const streamId = `<%= stream.pattern.replace(/\$\{([^}]+)\}/g, (_, key) => `\${commandData.${key}}`) %>`;
107
+ <% } else { -%>
108
+ const streamId = `<%= stream.pattern.replace(/\$\{([^}]+)\}/g, (_, key) => `\${command.data.${key}}`) %>`;
109
+ <% } -%>
90
110
  <% } else { -%>
91
111
  const streamId = '<%= stream?.pattern ?? 'unknown-stream' %>';
92
112
  <% } -%>
@@ -562,8 +562,11 @@ describe('projection.ts.ejs', () => {
562
562
  // SINGLETON AGGREGATION PATTERN
563
563
  // This projection maintains a single document that aggregates data from multiple entities.
564
564
  // Use internal state to track individual entity information for accurate calculations.
565
+
566
+ type EntityData = TodoAdded['data'];
567
+
565
568
  interface InternalTodoSummary extends TodoSummary {
566
- _entities?: Record<string, { status?: string; [key: string]: unknown }>;
569
+ _entities?: Record<string, EntityData>;
567
570
  }
568
571
 
569
572
  type AllEvents = TodoAdded;
@@ -45,8 +45,14 @@ import { <%= stateEnums.join(', ') %> } from '../../../shared';
45
45
  // SINGLETON AGGREGATION PATTERN
46
46
  // This projection maintains a single document that aggregates data from multiple entities.
47
47
  // Use internal state to track individual entity information for accurate calculations.
48
+ <% if (events.length === 1) { %>
49
+ type EntityData = <%= pascalCase(events[0].type) %>['data'];
50
+ <% } else { %>
51
+ type EntityData = Partial<<%= events.map(e => pascalCase(e.type) + "['data']").join(' & ') %>>;
52
+ <% } %>
53
+
48
54
  interface Internal<%= pascalCase(targetName || 'State') %> extends <%= pascalCase(targetName || 'State') %> {
49
- _entities?: Record<string, { status?: string; [key: string]: unknown }>;
55
+ _entities?: Record<string, EntityData>;
50
56
  }
51
57
  <% } %>
52
58
  type AllEvents = <%= allEventTypes %>;