@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
@@ -2793,4 +2793,129 @@ describe('spec.ts.ejs', () => {
2793
2793
  "
2794
2794
  `);
2795
2795
  });
2796
+
2797
+ it('should use preceding sibling events for state-ref Given on shared stream', async () => {
2798
+ const spec: SpecsSchema = {
2799
+ variant: 'specs',
2800
+ scenes: [
2801
+ {
2802
+ name: 'Gym session',
2803
+ moments: [
2804
+ {
2805
+ type: 'command',
2806
+ name: 'Start session',
2807
+ client: { specs: [] },
2808
+ server: {
2809
+ description: '',
2810
+ data: {
2811
+ items: [
2812
+ {
2813
+ target: { type: 'Event', name: 'SessionStarted' },
2814
+ destination: { type: 'stream', pattern: 'sessions-${id}' },
2815
+ },
2816
+ ],
2817
+ },
2818
+ specs: [
2819
+ {
2820
+ type: 'gherkin',
2821
+ feature: 'Start session',
2822
+ rules: [
2823
+ {
2824
+ name: 'Start session',
2825
+ examples: [
2826
+ {
2827
+ name: 'Start session example',
2828
+ steps: [
2829
+ { keyword: 'When', text: 'StartSession', docString: { userId: 'u1' } },
2830
+ { keyword: 'Then', text: 'SessionStarted', docString: { id: 's1', userId: 'u1' } },
2831
+ ],
2832
+ },
2833
+ ],
2834
+ },
2835
+ ],
2836
+ },
2837
+ ],
2838
+ },
2839
+ },
2840
+ {
2841
+ type: 'command',
2842
+ name: 'Complete session',
2843
+ client: { specs: [] },
2844
+ server: {
2845
+ description: '',
2846
+ data: {
2847
+ items: [
2848
+ {
2849
+ target: { type: 'Event', name: 'SessionCompleted' },
2850
+ destination: { type: 'stream', pattern: 'sessions-${sessionId}' },
2851
+ },
2852
+ ],
2853
+ },
2854
+ specs: [
2855
+ {
2856
+ type: 'gherkin',
2857
+ feature: 'Complete session',
2858
+ rules: [
2859
+ {
2860
+ name: 'Complete session',
2861
+ examples: [
2862
+ {
2863
+ name: 'Complete session example',
2864
+ steps: [
2865
+ { keyword: 'Given', text: 'ActiveSession', docString: { status: 'active' } },
2866
+ { keyword: 'When', text: 'CompleteSession', docString: { sessionId: 's1' } },
2867
+ {
2868
+ keyword: 'Then',
2869
+ text: 'SessionCompleted',
2870
+ docString: { sessionId: 's1', completedAt: '2024-01-15' },
2871
+ },
2872
+ ],
2873
+ },
2874
+ ],
2875
+ },
2876
+ ],
2877
+ },
2878
+ ],
2879
+ },
2880
+ },
2881
+ ],
2882
+ },
2883
+ ],
2884
+ messages: [
2885
+ { type: 'command', name: 'StartSession', fields: [{ name: 'userId', type: 'string', required: true }] },
2886
+ { type: 'command', name: 'CompleteSession', fields: [{ name: 'sessionId', type: 'string', required: true }] },
2887
+ {
2888
+ type: 'event',
2889
+ name: 'SessionStarted',
2890
+ source: 'internal',
2891
+ fields: [
2892
+ { name: 'id', type: 'string', required: true },
2893
+ { name: 'userId', type: 'string', required: true },
2894
+ ],
2895
+ },
2896
+ {
2897
+ type: 'event',
2898
+ name: 'SessionCompleted',
2899
+ source: 'internal',
2900
+ fields: [
2901
+ { name: 'sessionId', type: 'string', required: true },
2902
+ { name: 'completedAt', type: 'string', required: true },
2903
+ ],
2904
+ },
2905
+ { type: 'state', name: 'ActiveSession', fields: [{ name: 'status', type: 'string', required: true }] },
2906
+ ],
2907
+ };
2908
+
2909
+ const { plans } = await generateScaffoldFilePlans(spec.scenes, spec.messages, undefined, 'src/domain/narratives');
2910
+
2911
+ const completeSpecs = plans.find(
2912
+ (p) => p.outputPath.includes('complete-session') && p.outputPath.endsWith('decide.specs.ts'),
2913
+ );
2914
+ const contents = completeSpecs?.contents ?? '';
2915
+
2916
+ expect(contents).toContain("import type { SessionStarted } from '../start-session/events';");
2917
+ expect(contents).toContain("type: 'SessionStarted'");
2918
+ expect(contents).toContain("id: 's1'");
2919
+ expect(contents).toContain("userId: 'u1'");
2920
+ });
2796
2921
  });
@@ -593,7 +593,7 @@ describe('decide.ts.ejs', () => {
593
593
  import type { ItemsSuggested } from './events';
594
594
  import type { Products } from '@auto-engineer/product-catalogue-integration';
595
595
 
596
- export const decide = (command: SuggestItems, _state: State, products?: Products): ItemsSuggested => {
596
+ export const decide = (command: SuggestItems, _state: State, context?: { products?: Products }): ItemsSuggested => {
597
597
  switch (command.type) {
598
598
  case 'SuggestItems': {
599
599
  /**
@@ -604,7 +604,7 @@ describe('decide.ts.ejs', () => {
604
604
  * You should:
605
605
  * - Validate the command input fields
606
606
  * - NEVER use \`as SomeType\` type assertions — not \`as any\`, not \`as EventType\`, no casts at all. Use typed variable declarations.
607
- * - Use \`products\` (integration result) to enrich or filter the output
607
+ * - Use \`context?.products\` (integration result) to enrich or filter the output
608
608
  * - If invalid, throw one of the following domain errors: \`IllegalStateError\`
609
609
  * ⚠️ Error constructors: IllegalStateError takes a string message
610
610
  * - If valid, return one or more events with the correct structure
@@ -613,7 +613,7 @@ describe('decide.ts.ejs', () => {
613
613
  * ⚠️ Only read from inputs — never mutate them. \`evolve.ts\` handles state updates.
614
614
  *
615
615
  * Integration result shape (Products):
616
- * products?.data = {
616
+ * context?.products?.data = {
617
617
  * products: Array<{
618
618
  * id: string;
619
619
  * name: string;
@@ -648,4 +648,259 @@ describe('decide.ts.ejs', () => {
648
648
  "
649
649
  `);
650
650
  });
651
+
652
+ it('should generate context parameter and uuid assignment for stream uuid vars that are event fields', async () => {
653
+ const spec: SpecsSchema = {
654
+ variant: 'specs',
655
+ scenes: [
656
+ {
657
+ name: 'Fitness tracker',
658
+ moments: [
659
+ {
660
+ type: 'command',
661
+ name: 'Log workout',
662
+ stream: 'workouts-${workoutId}',
663
+ client: { specs: [] },
664
+ server: {
665
+ description: 'test',
666
+ specs: [
667
+ {
668
+ type: 'gherkin',
669
+ feature: 'Log workout',
670
+ rules: [
671
+ {
672
+ name: 'Should log workout',
673
+ examples: [
674
+ {
675
+ name: 'Workout logged',
676
+ steps: [
677
+ {
678
+ keyword: 'When',
679
+ text: 'LogWorkout',
680
+ docString: { exercise: 'squat', reps: 10 },
681
+ },
682
+ {
683
+ keyword: 'Then',
684
+ text: 'WorkoutLogged',
685
+ docString: { workoutId: 'wk-1', exercise: 'squat', reps: 10 },
686
+ },
687
+ ],
688
+ },
689
+ ],
690
+ },
691
+ ],
692
+ },
693
+ ],
694
+ data: {
695
+ items: [
696
+ {
697
+ target: { type: 'Event', name: 'WorkoutLogged' },
698
+ destination: { type: 'stream', pattern: 'workouts-${workoutId}' },
699
+ },
700
+ ],
701
+ },
702
+ },
703
+ },
704
+ ],
705
+ },
706
+ ],
707
+ messages: [
708
+ {
709
+ type: 'command',
710
+ name: 'LogWorkout',
711
+ fields: [
712
+ { name: 'exercise', type: 'string', required: true },
713
+ { name: 'reps', type: 'number', required: true },
714
+ ],
715
+ },
716
+ {
717
+ type: 'event',
718
+ name: 'WorkoutLogged',
719
+ source: 'internal',
720
+ fields: [
721
+ { name: 'workoutId', type: 'string', required: true },
722
+ { name: 'exercise', type: 'string', required: true },
723
+ { name: 'reps', type: 'number', required: true },
724
+ ],
725
+ },
726
+ ],
727
+ };
728
+
729
+ const { plans } = await generateScaffoldFilePlans(spec.scenes, spec.messages, undefined, 'src/domain/narratives');
730
+ const decideFile = plans.find((p) => p.outputPath.endsWith('decide.ts'));
731
+
732
+ expect(decideFile?.contents).toMatchInlineSnapshot(`
733
+ "import { randomUUID } from 'node:crypto';
734
+ import { IllegalStateError } from '@event-driven-io/emmett';
735
+ import type { State } from './state';
736
+ import type { LogWorkout } from './commands';
737
+ import type { WorkoutLogged } from './events';
738
+
739
+ export const decide = (
740
+ command: LogWorkout,
741
+ _state: State,
742
+ context?: { generated?: { workoutId: string } },
743
+ ): WorkoutLogged => {
744
+ switch (command.type) {
745
+ case 'LogWorkout': {
746
+ /**
747
+ * ## IMPLEMENTATION INSTRUCTIONS ##
748
+ *
749
+ * This command can directly emit one or more events based on the input.
750
+ *
751
+ * You should:
752
+ * - Validate the command input fields
753
+ * - NEVER use \`as SomeType\` type assertions — not \`as any\`, not \`as EventType\`, no casts at all. Use typed variable declarations.
754
+ * - If invalid, throw one of the following domain errors: \`IllegalStateError\`
755
+ * ⚠️ Error constructors: IllegalStateError takes a string message
756
+ * - If valid, return one or more events with the correct structure
757
+ *
758
+ * - Only destructure/reference command.data fields that exist in the command type (imported from ./commands). Do NOT access fields not declared in the type, even if example data includes them.
759
+ * ⚠️ Only read from inputs — never mutate them. \`evolve.ts\` handles state updates.
760
+
761
+ * Business rules:
762
+ * - Should log workout
763
+ */
764
+
765
+ const workoutId = context?.generated?.workoutId ?? randomUUID();
766
+
767
+ // IMPLEMENT: Use a typed variable to prevent type widening:
768
+ // const result: WorkoutLogged = {
769
+ // type: 'WorkoutLogged',
770
+ // data: { ...command.data, workoutId },
771
+ // };
772
+ // return result;
773
+
774
+ throw new IllegalStateError('Not yet implemented: ' + command.type);
775
+ }
776
+ default:
777
+ throw new IllegalStateError('Unexpected command type');
778
+ }
779
+ };
780
+ "
781
+ `);
782
+ });
783
+
784
+ it('should say Inspect _state for state-ref Given with preceding siblings', async () => {
785
+ const spec: SpecsSchema = {
786
+ variant: 'specs',
787
+ scenes: [
788
+ {
789
+ name: 'Gym session',
790
+ moments: [
791
+ {
792
+ type: 'command',
793
+ name: 'Start session',
794
+ client: { specs: [] },
795
+ server: {
796
+ description: '',
797
+ data: {
798
+ items: [
799
+ {
800
+ target: { type: 'Event', name: 'SessionStarted' },
801
+ destination: { type: 'stream', pattern: 'sessions-${id}' },
802
+ },
803
+ ],
804
+ },
805
+ specs: [
806
+ {
807
+ type: 'gherkin',
808
+ feature: 'Start',
809
+ rules: [
810
+ {
811
+ name: 'Start',
812
+ examples: [
813
+ {
814
+ name: 'Start',
815
+ steps: [
816
+ { keyword: 'When', text: 'StartSession', docString: { userId: 'u1' } },
817
+ { keyword: 'Then', text: 'SessionStarted', docString: { id: 's1', userId: 'u1' } },
818
+ ],
819
+ },
820
+ ],
821
+ },
822
+ ],
823
+ },
824
+ ],
825
+ },
826
+ },
827
+ {
828
+ type: 'command',
829
+ name: 'Complete session',
830
+ client: { specs: [] },
831
+ server: {
832
+ description: '',
833
+ data: {
834
+ items: [
835
+ {
836
+ target: { type: 'Event', name: 'SessionCompleted' },
837
+ destination: { type: 'stream', pattern: 'sessions-${sessionId}' },
838
+ },
839
+ ],
840
+ },
841
+ specs: [
842
+ {
843
+ type: 'gherkin',
844
+ feature: 'Complete',
845
+ rules: [
846
+ {
847
+ name: 'Complete',
848
+ examples: [
849
+ {
850
+ name: 'Complete',
851
+ steps: [
852
+ { keyword: 'Given', text: 'ActiveSession', docString: { status: 'active' } },
853
+ { keyword: 'When', text: 'CompleteSession', docString: { sessionId: 's1' } },
854
+ {
855
+ keyword: 'Then',
856
+ text: 'SessionCompleted',
857
+ docString: { sessionId: 's1', completedAt: '2024-01-15' },
858
+ },
859
+ ],
860
+ },
861
+ ],
862
+ },
863
+ ],
864
+ },
865
+ ],
866
+ },
867
+ },
868
+ ],
869
+ },
870
+ ],
871
+ messages: [
872
+ { type: 'command', name: 'StartSession', fields: [{ name: 'userId', type: 'string', required: true }] },
873
+ { type: 'command', name: 'CompleteSession', fields: [{ name: 'sessionId', type: 'string', required: true }] },
874
+ {
875
+ type: 'event',
876
+ name: 'SessionStarted',
877
+ source: 'internal',
878
+ fields: [
879
+ { name: 'id', type: 'string', required: true },
880
+ { name: 'userId', type: 'string', required: true },
881
+ ],
882
+ },
883
+ {
884
+ type: 'event',
885
+ name: 'SessionCompleted',
886
+ source: 'internal',
887
+ fields: [
888
+ { name: 'sessionId', type: 'string', required: true },
889
+ { name: 'completedAt', type: 'string', required: true },
890
+ ],
891
+ },
892
+ { type: 'state', name: 'ActiveSession', fields: [{ name: 'status', type: 'string', required: true }] },
893
+ ],
894
+ };
895
+
896
+ const { plans } = await generateScaffoldFilePlans(spec.scenes, spec.messages, undefined, 'src/domain/narratives');
897
+ const decideFile = plans.find(
898
+ (p) => p.outputPath.includes('complete-session') && p.outputPath.endsWith('decide.ts'),
899
+ );
900
+ const contents = decideFile?.contents ?? '';
901
+
902
+ expect(contents).toContain('requires evaluating prior state');
903
+ expect(contents).toContain('Inspect the current domain');
904
+ expect(contents).not.toContain('succeed from initial state');
905
+ });
651
906
  });
@@ -1,6 +1,7 @@
1
1
  <%
2
2
  const allEvents = [];
3
3
  const ruleGroups = new Map();
4
+ const _stateRefGivenSeen = new Set();
4
5
  for (const commandName in gwtMapping) {
5
6
  const cases = gwtMapping[commandName];
6
7
  for (const gwt of cases) {
@@ -12,8 +13,17 @@ for (const commandName in gwtMapping) {
12
13
  if (gwt.given && gwt.given.length) {
13
14
  for (const g of gwt.given) {
14
15
  if (g.eventRef) {
15
- const event = events.find(e => e.type === g.eventRef);
16
- if (event) allEvents.push(event);
16
+ const event = allStreamEvents.find(e => e.type === g.eventRef);
17
+ if (event) {
18
+ allEvents.push(event);
19
+ } else if (precedingSiblingEvents.length > 0 && !_stateRefGivenSeen.has(g.eventRef)) {
20
+ _stateRefGivenSeen.add(g.eventRef);
21
+ for (const se of precedingSiblingEvents) {
22
+ if (!allEvents.some(e => e.type === se.type)) {
23
+ allEvents.push(se);
24
+ }
25
+ }
26
+ }
17
27
  }
18
28
  }
19
29
  }
@@ -33,7 +43,7 @@ const testEventsByPath = new Map();
33
43
 
34
44
  for (const event of allEvents) {
35
45
  if (!event.type) continue;
36
- const importGroup = eventImportGroups.find(group =>
46
+ const importGroup = allStreamEventImportGroups.find(group =>
37
47
  group.eventTypes.includes(event.type)
38
48
  );
39
49
 
@@ -64,7 +74,7 @@ for (const commandName in gwtMapping) {
64
74
  const eventResults = gwt.then.filter(t => 'eventRef' in t);
65
75
  const errorResult = gwt.then.find(t => 'errorType' in t);
66
76
  if (errorResult) continue;
67
- const givenEventRefs = (gwt.given || []).filter(g => events.some(ev => ev.type === g.eventRef));
77
+ const givenEventRefs = (gwt.given || []).filter(g => allStreamEvents.some(ev => ev.type === g.eventRef));
68
78
  for (const e of eventResults) {
69
79
  const dedupeKey = `${commandName}::${e.eventRef}`;
70
80
  if (seenCommandEvents.has(dedupeKey)) continue;
@@ -116,12 +126,22 @@ describe('<%= escapeJsString(ruleDescription) %>', () => {
116
126
  %>
117
127
  it('<%= escapeJsString(testDescription) %>', () => {
118
128
  given([
119
- <%_ const givenEvents = (gwt.given || []).filter(g => events.some(e => e.type === g.eventRef)); _%>
129
+ <%_
130
+ let givenEvents = (gwt.given || []).filter(g => allStreamEvents.some(e => e.type === g.eventRef));
131
+ const hasStateRefGiven = (gwt.given || []).some(g => !allStreamEvents.some(e => e.type === g.eventRef));
132
+ if (givenEvents.length === 0 && hasStateRefGiven && precedingSiblingEvents.length > 0) {
133
+ givenEvents = precedingSiblingEvents;
134
+ }
135
+ _%>
120
136
  <%_ if (givenEvents.length) { _%>
121
- <%- givenEvents.map(g => `{
122
- type: '${g.eventRef}',
123
- data: ${formatDataObject(g.exampleData, events.find(e => e.type === g.eventRef))}
124
- }`).join(',\n ') %>
137
+ <%- givenEvents.map(g => {
138
+ const schema = allStreamEvents.find(e => e.type === (g.eventRef || g.type));
139
+ const data = g.exampleData || siblingExampleData[g.type] || {};
140
+ return `{
141
+ type: '${g.eventRef || g.type}',
142
+ data: ${formatDataObject(data, schema)}
143
+ }`;
144
+ }).join(',\n ') %>
125
145
  <%_ } _%>
126
146
  ])
127
147
  .when({
@@ -171,7 +191,11 @@ describe('field completeness', () => {
171
191
  const firstScenario = scenarios[0];
172
192
  const gwt = firstScenario.gwt;
173
193
  const schema = commandSchemasByName[cmdName];
174
- const givenEvents = (gwt.given || []).filter(g => events.some(e => e.type === g.eventRef));
194
+ let givenEvents = (gwt.given || []).filter(g => allStreamEvents.some(e => e.type === g.eventRef));
195
+ const hasStateRefGiven = (gwt.given || []).some(g => !allStreamEvents.some(e => e.type === g.eventRef));
196
+ if (givenEvents.length === 0 && hasStateRefGiven && precedingSiblingEvents.length > 0) {
197
+ givenEvents = precedingSiblingEvents;
198
+ }
175
199
  const example = gwt.when;
176
200
  const cmdFieldNames = firstScenario.cmdFieldNames;
177
201
  const filteredCmdData = Object.fromEntries(
@@ -181,10 +205,14 @@ describe('field completeness', () => {
181
205
  it('should include all required fields in <%= scenarios.map(s => s.eventRef).join(', ') %>', () => {
182
206
  <%_ if (givenEvents.length) { _%>
183
207
  const state = [
184
- <%- givenEvents.map(g => `{
185
- type: '${g.eventRef}' as const,
186
- data: ${formatDataObject(g.exampleData, events.find(e => e.type === g.eventRef))}
187
- }`).join(',\n ') %>
208
+ <%- givenEvents.map(g => {
209
+ const schema = allStreamEvents.find(e => e.type === (g.eventRef || g.type));
210
+ const data = g.exampleData || siblingExampleData[g.type] || {};
211
+ return `{
212
+ type: '${g.eventRef || g.type}' as const,
213
+ data: ${formatDataObject(data, schema)}
214
+ }`;
215
+ }).join(',\n ') %>
188
216
  ].reduce((s, e) => evolve(s, e), initialState());
189
217
  <%_ } else { _%>
190
218
  const state = initialState();