@auto-engineer/server-generator-apollo-emmett 1.144.0 → 1.146.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 (36) 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 +55 -0
  5. package/dist/src/codegen/extract/imports.js +1 -1
  6. package/dist/src/codegen/extract/imports.js.map +1 -1
  7. package/dist/src/codegen/scaffoldFromSchema.d.ts +11 -1
  8. package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
  9. package/dist/src/codegen/scaffoldFromSchema.js +52 -9
  10. package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
  11. package/dist/src/codegen/templates/command/handle.specs.ts +201 -0
  12. package/dist/src/codegen/templates/command/handle.ts.ejs +34 -0
  13. package/dist/src/codegen/templates/query/projection.specs.ts +3 -10
  14. package/dist/src/codegen/templates/query/query.resolver.specs.ts +6 -20
  15. package/dist/src/codegen/templates/query/query.resolver.ts.ejs +14 -5
  16. package/dist/src/codegen/templates/react/react.specs.ts.ejs +2 -2
  17. package/dist/src/codegen/templates/react/react.ts.ejs +1 -1
  18. package/dist/src/codegen/templates/react/register.ts.ejs +1 -1
  19. package/dist/src/codegen/types.d.ts +1 -0
  20. package/dist/src/codegen/types.d.ts.map +1 -1
  21. package/dist/tsconfig.tsbuildinfo +1 -1
  22. package/ketchup-plan.md +5 -6
  23. package/package.json +4 -4
  24. package/src/codegen/buildCrossSceneGivens.specs.ts +263 -0
  25. package/src/codegen/extract/imports.specs.ts +26 -0
  26. package/src/codegen/extract/imports.ts +1 -1
  27. package/src/codegen/scaffoldFromSchema.ts +73 -5
  28. package/src/codegen/templates/command/handle.specs.ts +201 -0
  29. package/src/codegen/templates/command/handle.ts.ejs +34 -0
  30. package/src/codegen/templates/query/projection.specs.ts +3 -10
  31. package/src/codegen/templates/query/query.resolver.specs.ts +6 -20
  32. package/src/codegen/templates/query/query.resolver.ts.ejs +14 -5
  33. package/src/codegen/templates/react/react.specs.ts.ejs +2 -2
  34. package/src/codegen/templates/react/react.ts.ejs +1 -1
  35. package/src/codegen/templates/react/register.ts.ejs +1 -1
  36. package/src/codegen/types.ts +1 -0
package/ketchup-plan.md CHANGED
@@ -1,10 +1,9 @@
1
- # Ketchup Plan: Fix Unescaped Quotes in EJS Template String Literals
1
+ # Ketchup Plan: Cross-Scene Given Events in handle.ts
2
2
 
3
3
  ## TODO
4
4
 
5
- - [x] Burst 1: Add `escapeJsString` utility + unit tests + wire into renderTemplate (7a5544a6)
6
- - [x] Burst 2: Fix `projection.specs.ts.ejs` interpolation points (d108c530)
7
- - [x] Burst 3: Fix `decide.specs.ts.ejs` and `decide.ts.ejs` interpolation points (f9b46ada)
8
- - [x] Burst 4: Fix `react.specs.ts.ejs` interpolation points
9
-
10
5
  ## DONE
6
+
7
+ - [x] Burst 1: Compute `crossSceneGivens` in template data (d5786a13)
8
+ - [x] Burst 2: Generate cross-scene handler in handle.ts.ejs (f919ab8b)
9
+ - [x] Burst 3: End-to-end verification — all 299 tests pass, existing snapshots unchanged
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.144.0",
36
- "@auto-engineer/message-bus": "1.144.0"
35
+ "@auto-engineer/narrative": "1.146.0",
36
+ "@auto-engineer/message-bus": "1.146.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.144.0"
47
+ "@auto-engineer/cli": "1.146.0"
48
48
  },
49
- "version": "1.144.0",
49
+ "version": "1.146.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",
@@ -0,0 +1,263 @@
1
+ import type { Scene } from '@auto-engineer/narrative';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { buildCrossSceneGivens } from './scaffoldFromSchema';
4
+ import type { GwtCondition, Message } from './types';
5
+
6
+ describe('buildCrossSceneGivens', () => {
7
+ const workoutsScene: Scene = {
8
+ name: 'Workouts',
9
+ moments: [
10
+ {
11
+ type: 'command',
12
+ name: 'Create workout',
13
+ stream: 'workout-${workoutId}',
14
+ client: { specs: [] },
15
+ server: {
16
+ description: '',
17
+ specs: [
18
+ {
19
+ type: 'gherkin',
20
+ feature: 'Create workout',
21
+ rules: [
22
+ {
23
+ name: 'Should create',
24
+ examples: [
25
+ {
26
+ name: 'Workout created',
27
+ steps: [
28
+ { keyword: 'When', text: 'CreateWorkout', docString: { workoutId: 'w1' } },
29
+ { keyword: 'Then', text: 'WorkoutCreated', docString: { workoutId: 'w1' } },
30
+ ],
31
+ },
32
+ ],
33
+ },
34
+ ],
35
+ },
36
+ ],
37
+ data: {
38
+ items: [
39
+ {
40
+ target: { type: 'Event', name: 'WorkoutCreated' },
41
+ destination: { type: 'stream', pattern: 'workout-${workoutId}' },
42
+ },
43
+ ],
44
+ },
45
+ },
46
+ },
47
+ ],
48
+ };
49
+
50
+ const progressScene: Scene = {
51
+ name: 'Progress',
52
+ moments: [
53
+ {
54
+ type: 'command',
55
+ name: 'Record progress',
56
+ stream: 'progress-${progressId}',
57
+ client: { specs: [] },
58
+ server: {
59
+ description: '',
60
+ specs: [
61
+ {
62
+ type: 'gherkin',
63
+ feature: 'Record progress',
64
+ rules: [
65
+ {
66
+ name: 'Should record',
67
+ examples: [
68
+ {
69
+ name: 'Progress recorded',
70
+ steps: [
71
+ { keyword: 'Given', text: 'WorkoutCreated', docString: { workoutId: 'w1' } },
72
+ {
73
+ keyword: 'When',
74
+ text: 'RecordProgress',
75
+ docString: { workoutId: 'w1', date: '2024-01-01' },
76
+ },
77
+ {
78
+ keyword: 'Then',
79
+ text: 'ProgressRecorded',
80
+ docString: { workoutId: 'w1', date: '2024-01-01' },
81
+ },
82
+ ],
83
+ },
84
+ ],
85
+ },
86
+ ],
87
+ },
88
+ ],
89
+ data: {
90
+ items: [
91
+ {
92
+ target: { type: 'Event', name: 'ProgressRecorded' },
93
+ destination: { type: 'stream', pattern: 'progress-${progressId}' },
94
+ },
95
+ ],
96
+ },
97
+ },
98
+ },
99
+ ],
100
+ };
101
+
102
+ it('detects cross-scene Given with resolved linking fields', () => {
103
+ const gwtMapping: Record<string, GwtCondition[]> = {
104
+ RecordProgress: [
105
+ {
106
+ given: [{ eventRef: 'WorkoutCreated', exampleData: { workoutId: 'w1' } }],
107
+ when: { commandRef: 'RecordProgress', exampleData: { workoutId: 'w1', date: '2024-01-01' } },
108
+ then: [{ eventRef: 'ProgressRecorded', exampleData: { workoutId: 'w1', date: '2024-01-01' } }],
109
+ },
110
+ ],
111
+ };
112
+
113
+ const events: Message[] = [
114
+ {
115
+ type: 'WorkoutCreated',
116
+ fields: [{ name: 'workoutId', tsType: 'string', required: true }],
117
+ sourceSceneName: 'Workouts',
118
+ sourceMomentName: 'Create workout',
119
+ },
120
+ {
121
+ type: 'ProgressRecorded',
122
+ fields: [
123
+ { name: 'workoutId', tsType: 'string', required: true },
124
+ { name: 'date', tsType: 'string', required: true },
125
+ ],
126
+ sourceSceneName: 'Progress',
127
+ sourceMomentName: 'Record progress',
128
+ },
129
+ ];
130
+
131
+ const commands: Message[] = [
132
+ {
133
+ type: 'RecordProgress',
134
+ fields: [
135
+ { name: 'workoutId', tsType: 'string', required: true },
136
+ { name: 'date', tsType: 'string', required: true },
137
+ ],
138
+ },
139
+ ];
140
+
141
+ const result = buildCrossSceneGivens(gwtMapping, events, progressScene, [workoutsScene, progressScene], commands);
142
+
143
+ expect(result).toEqual([
144
+ {
145
+ sourceStreamPattern: 'workout-${workoutId}',
146
+ linkingFields: [{ streamVar: 'workoutId', commandField: 'workoutId' }],
147
+ allVarsResolved: true,
148
+ },
149
+ ]);
150
+ });
151
+
152
+ it('returns empty array when no cross-scene Givens exist', () => {
153
+ const gwtMapping: Record<string, GwtCondition[]> = {
154
+ CreateWorkout: [
155
+ {
156
+ when: { commandRef: 'CreateWorkout', exampleData: { workoutId: 'w1' } },
157
+ then: [{ eventRef: 'WorkoutCreated', exampleData: { workoutId: 'w1' } }],
158
+ },
159
+ ],
160
+ };
161
+
162
+ const events: Message[] = [
163
+ {
164
+ type: 'WorkoutCreated',
165
+ fields: [{ name: 'workoutId', tsType: 'string', required: true }],
166
+ sourceSceneName: 'Workouts',
167
+ sourceMomentName: 'Create workout',
168
+ },
169
+ ];
170
+
171
+ const commands: Message[] = [
172
+ {
173
+ type: 'CreateWorkout',
174
+ fields: [{ name: 'workoutId', tsType: 'string', required: true }],
175
+ },
176
+ ];
177
+
178
+ const result = buildCrossSceneGivens(gwtMapping, events, workoutsScene, [workoutsScene], commands);
179
+
180
+ expect(result).toEqual([]);
181
+ });
182
+
183
+ it('marks unresolved when stream var has no matching command field', () => {
184
+ const gwtMapping: Record<string, GwtCondition[]> = {
185
+ RecordProgress: [
186
+ {
187
+ given: [{ eventRef: 'WorkoutCreated', exampleData: { workoutId: 'w1' } }],
188
+ when: { commandRef: 'RecordProgress', exampleData: { date: '2024-01-01' } },
189
+ then: [{ eventRef: 'ProgressRecorded', exampleData: { date: '2024-01-01' } }],
190
+ },
191
+ ],
192
+ };
193
+
194
+ const events: Message[] = [
195
+ {
196
+ type: 'WorkoutCreated',
197
+ fields: [{ name: 'workoutId', tsType: 'string', required: true }],
198
+ sourceSceneName: 'Workouts',
199
+ sourceMomentName: 'Create workout',
200
+ },
201
+ ];
202
+
203
+ const commands: Message[] = [
204
+ {
205
+ type: 'RecordProgress',
206
+ fields: [{ name: 'date', tsType: 'string', required: true }],
207
+ },
208
+ ];
209
+
210
+ const result = buildCrossSceneGivens(gwtMapping, events, progressScene, [workoutsScene, progressScene], commands);
211
+
212
+ expect(result).toEqual([
213
+ {
214
+ sourceStreamPattern: 'workout-${workoutId}',
215
+ linkingFields: [],
216
+ allVarsResolved: false,
217
+ },
218
+ ]);
219
+ });
220
+
221
+ it('deduplicates by source stream pattern', () => {
222
+ const gwtMapping: Record<string, GwtCondition[]> = {
223
+ RecordProgress: [
224
+ {
225
+ given: [{ eventRef: 'WorkoutCreated', exampleData: { workoutId: 'w1' } }],
226
+ when: { commandRef: 'RecordProgress', exampleData: { workoutId: 'w1' } },
227
+ then: [{ eventRef: 'ProgressRecorded', exampleData: { workoutId: 'w1' } }],
228
+ },
229
+ {
230
+ given: [{ eventRef: 'WorkoutCreated', exampleData: { workoutId: 'w2' } }],
231
+ when: { commandRef: 'RecordProgress', exampleData: { workoutId: 'w2' } },
232
+ then: [{ eventRef: 'ProgressRecorded', exampleData: { workoutId: 'w2' } }],
233
+ },
234
+ ],
235
+ };
236
+
237
+ const events: Message[] = [
238
+ {
239
+ type: 'WorkoutCreated',
240
+ fields: [{ name: 'workoutId', tsType: 'string', required: true }],
241
+ sourceSceneName: 'Workouts',
242
+ sourceMomentName: 'Create workout',
243
+ },
244
+ ];
245
+
246
+ const commands: Message[] = [
247
+ {
248
+ type: 'RecordProgress',
249
+ fields: [{ name: 'workoutId', tsType: 'string', required: true }],
250
+ },
251
+ ];
252
+
253
+ const result = buildCrossSceneGivens(gwtMapping, events, progressScene, [workoutsScene, progressScene], commands);
254
+
255
+ expect(result).toEqual([
256
+ {
257
+ sourceStreamPattern: 'workout-${workoutId}',
258
+ linkingFields: [{ streamVar: 'workoutId', commandField: 'workoutId' }],
259
+ allVarsResolved: true,
260
+ },
261
+ ]);
262
+ });
263
+ });
@@ -60,6 +60,32 @@ describe('groupEventImports', () => {
60
60
  ]);
61
61
  });
62
62
 
63
+ it('uses sourceSceneDirName for cross-scene imports when set', () => {
64
+ const events: Message[] = [
65
+ {
66
+ type: 'WorkoutCreated',
67
+ fields: [],
68
+ source: 'when',
69
+ sourceSceneName: 'gym workout creation',
70
+ sourceMomentName: 'create workout',
71
+ sourceSceneDirName: 'gym-tracker_gym-workout-creation',
72
+ },
73
+ ];
74
+
75
+ const result = groupEventImports({
76
+ currentMomentName: 'view workout progress',
77
+ currentSceneName: 'gym workout completion',
78
+ events,
79
+ });
80
+
81
+ expect(result).toEqual([
82
+ {
83
+ importPath: '../../gym-tracker_gym-workout-creation/create-workout/events',
84
+ eventTypes: ['WorkoutCreated'],
85
+ },
86
+ ]);
87
+ });
88
+
63
89
  it('handles mixed same-scene and cross-scene events', () => {
64
90
  const events: Message[] = [
65
91
  {
@@ -34,7 +34,7 @@ export function groupEventImports(context: CrossMomentImportContext): ImportGrou
34
34
  const isCrossScene = sourceSceneName != null && sourceSceneName !== currentSceneName;
35
35
  const sliceSegment = toKebabCase(event.sourceMomentName ?? currentMomentName);
36
36
  importPath = isCrossScene
37
- ? `../../${toKebabCase(sourceSceneName)}/${sliceSegment}/events`
37
+ ? `../../${event.sourceSceneDirName ?? toKebabCase(sourceSceneName)}/${sliceSegment}/events`
38
38
  : `../${sliceSegment}/events`;
39
39
  }
40
40
  if (!importGroups.has(importPath)) {
@@ -42,7 +42,7 @@ import {
42
42
  isValidTsIdentifier,
43
43
  sanitizeFieldType,
44
44
  } from './extract';
45
- import { getStreamFromSink } from './extract/data-sink';
45
+ import { extractStreamIdFields, getStreamFromSink } from './extract/data-sink';
46
46
  import { buildEventIdFieldMap } from './extract/projection';
47
47
  import { buildArgToStateFieldMap } from './extract/query';
48
48
  import { normalizeMomentForTemplate } from './extract/slice-normalizer';
@@ -709,6 +709,8 @@ async function prepareTemplateData(
709
709
  const argToStateFieldMap =
710
710
  slice.type === 'query' ? buildArgToStateFieldMap(queryGwtMapping, stateFieldNames, slice.mappings) : undefined;
711
711
 
712
+ const crossSceneGivens = buildCrossSceneGivens(gwtMapping, events, scene, scenes ?? [], filteredCommands);
713
+
712
714
  return {
713
715
  sceneName: scene.name,
714
716
  momentName: slice.name,
@@ -739,21 +741,77 @@ async function prepareTemplateData(
739
741
  eventCommandPairs,
740
742
  eventIdFieldMap,
741
743
  argToStateFieldMap,
744
+ crossSceneGivens,
742
745
  };
743
746
  }
744
747
 
748
+ interface CrossSceneGiven {
749
+ sourceStreamPattern: string;
750
+ linkingFields: Array<{
751
+ streamVar: string;
752
+ commandField: string;
753
+ }>;
754
+ allVarsResolved: boolean;
755
+ }
756
+
757
+ export function buildCrossSceneGivens(
758
+ gwtMapping: Record<string, GwtCondition[]>,
759
+ events: Message[],
760
+ currentScene: Scene,
761
+ scenes: Scene[],
762
+ filteredCommands: Message[],
763
+ ): CrossSceneGiven[] {
764
+ const commandFieldNames = new Set(filteredCommands.flatMap((c) => c.fields.map((f) => f.name)));
765
+ const seenPatterns = new Set<string>();
766
+ const result: CrossSceneGiven[] = [];
767
+
768
+ for (const conditions of Object.values(gwtMapping)) {
769
+ for (const gwt of conditions) {
770
+ for (const givenRef of gwt.given ?? []) {
771
+ if (!('eventRef' in givenRef)) continue;
772
+
773
+ const event = events.find((e) => e.type === givenRef.eventRef);
774
+ if (!event || event.sourceSceneName === currentScene.name) continue;
775
+
776
+ const sourceScene = scenes.find((s) => s.name === event.sourceSceneName);
777
+ const sourceMoment = sourceScene?.moments.find((m) => m.name === event.sourceMomentName);
778
+ if (!sourceMoment) continue;
779
+
780
+ const { streamPattern } = getStreamFromSink(sourceMoment);
781
+ if (!streamPattern || seenPatterns.has(streamPattern)) continue;
782
+ seenPatterns.add(streamPattern);
783
+
784
+ const streamVars = extractStreamIdFields(streamPattern);
785
+ const linkingFields = streamVars
786
+ .filter((v) => commandFieldNames.has(v))
787
+ .map((v) => ({ streamVar: v, commandField: v }));
788
+
789
+ result.push({
790
+ sourceStreamPattern: streamPattern,
791
+ linkingFields,
792
+ allVarsResolved: linkingFields.length === streamVars.length,
793
+ });
794
+ }
795
+ }
796
+ }
797
+
798
+ return result;
799
+ }
800
+
745
801
  function annotateEventSources(
746
802
  events: Message[],
747
803
  scenes: Scene[],
748
804
  fallbackFlowName: string,
749
805
  fallbackMomentName: string,
750
806
  momentType: string,
807
+ sceneNameToDirName?: Map<string, string>,
751
808
  ): void {
752
809
  debug('Annotating event sources for %d events', events.length);
753
810
  for (const event of events) {
754
811
  const match = findEventSource(scenes, event.type);
755
812
  event.sourceSceneName = match?.sceneName ?? fallbackFlowName;
756
813
  event.sourceMomentName = match?.momentName ?? (momentType === 'react' ? undefined : fallbackMomentName);
814
+ event.sourceSceneDirName = sceneNameToDirName?.get(event.sourceSceneName);
757
815
  debug(' Event %s: scene=%s, slice=%s', event.type, event.sourceSceneName, event.sourceMomentName);
758
816
  }
759
817
  }
@@ -799,12 +857,14 @@ function annotateCommandSources(
799
857
  fallbackFlowName: string,
800
858
  fallbackMomentName: string,
801
859
  momentType: string,
860
+ sceneNameToDirName?: Map<string, string>,
802
861
  ): void {
803
862
  debug('Annotating command sources for %d commands', commands.length);
804
863
  for (const command of commands) {
805
864
  const match = findCommandSource(scenes, command.type);
806
865
  command.sourceSceneName = match?.sceneName ?? fallbackFlowName;
807
866
  command.sourceMomentName = match?.momentName ?? (momentType === 'react' ? undefined : fallbackMomentName);
867
+ command.sourceSceneDirName = sceneNameToDirName?.get(command.sourceSceneName);
808
868
  debug(' Command %s: scene=%s, slice=%s', command.type, command.sourceSceneName, command.sourceMomentName);
809
869
  }
810
870
  }
@@ -835,6 +895,7 @@ async function generateFilesForMoment(
835
895
  unionToEnumName: Map<string, string>,
836
896
  integrations?: Model['integrations'],
837
897
  registeredCommands?: Map<string, { sceneName: string; momentName: string }>,
898
+ sceneNameToDirName?: Map<string, string>,
838
899
  ): Promise<{ plans: FilePlan[]; duplicateCommands: DuplicateCommandInfo[] }> {
839
900
  debugMoment('Generating files for moment: %s (type: %s)', slice.name, slice.type);
840
901
  debugMoment(' Scene: %s', scene.name);
@@ -867,8 +928,8 @@ async function generateFilesForMoment(
867
928
  slice.name,
868
929
  extracted.events.map((e) => e.type),
869
930
  );
870
- annotateEventSources(extracted.events, scenes, scene.name, slice.name, slice.type);
871
- annotateCommandSources(extracted.commands, scenes, scene.name, slice.name, slice.type);
931
+ annotateEventSources(extracted.events, scenes, scene.name, slice.name, slice.type, sceneNameToDirName);
932
+ annotateCommandSources(extracted.commands, scenes, scene.name, slice.name, slice.type, sceneNameToDirName);
872
933
 
873
934
  let filteredTemplates = templates;
874
935
 
@@ -986,12 +1047,18 @@ export async function generateScaffoldFilePlans(
986
1047
  }
987
1048
  }
988
1049
 
1050
+ const sceneNameToDirName = new Map<string, string>();
989
1051
  for (const scene of scenes) {
990
- debugScene('Processing scene: %s', scene.name);
991
1052
  const narrativeName = scene.id ? sceneToNarrative.get(scene.id) : undefined;
992
- const sceneDirName = narrativeName
1053
+ const dirName = narrativeName
993
1054
  ? `${toKebabCase(narrativeName)}_${toKebabCase(scene.name)}`
994
1055
  : toKebabCase(scene.name);
1056
+ sceneNameToDirName.set(scene.name, dirName);
1057
+ }
1058
+
1059
+ for (const scene of scenes) {
1060
+ debugScene('Processing scene: %s', scene.name);
1061
+ const sceneDirName = sceneNameToDirName.get(scene.name)!;
995
1062
  const sceneDir = ensureDirPath(baseDir, sceneDirName);
996
1063
  debugScene(' Scene directory: %s', sceneDir);
997
1064
  debugScene(' Number of moments: %d', scene.moments.length);
@@ -1014,6 +1081,7 @@ export async function generateScaffoldFilePlans(
1014
1081
  unionToEnumName,
1015
1082
  integrations,
1016
1083
  registeredCommands,
1084
+ sceneNameToDirName,
1017
1085
  );
1018
1086
  debugScene(' Generated %d plans for moment', plans.length);
1019
1087
  allPlans.push(...plans);