@auto-engineer/server-generator-apollo-emmett 1.129.0 → 1.131.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 +65 -0
  5. package/dist/src/codegen/extract/imports.d.ts +0 -5
  6. package/dist/src/codegen/extract/imports.d.ts.map +1 -1
  7. package/dist/src/codegen/extract/imports.js +0 -7
  8. package/dist/src/codegen/extract/imports.js.map +1 -1
  9. package/dist/src/codegen/extract/type-helpers.d.ts +1 -0
  10. package/dist/src/codegen/extract/type-helpers.d.ts.map +1 -1
  11. package/dist/src/codegen/extract/type-helpers.js +3 -0
  12. package/dist/src/codegen/extract/type-helpers.js.map +1 -1
  13. package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
  14. package/dist/src/codegen/scaffoldFromSchema.js +5 -3
  15. package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
  16. package/dist/src/codegen/templates/command/decide.specs.ts.ejs +3 -3
  17. package/dist/src/codegen/templates/command/decide.ts.ejs +1 -1
  18. package/dist/src/codegen/templates/query/events.specs.ts +112 -0
  19. package/dist/src/codegen/templates/query/events.ts.ejs +17 -0
  20. package/dist/src/codegen/templates/query/projection.specs.specs.ts +125 -0
  21. package/dist/src/codegen/templates/query/projection.specs.ts.ejs +2 -2
  22. package/dist/src/codegen/templates/react/react.specs.ts.ejs +2 -2
  23. package/dist/tsconfig.tsbuildinfo +1 -1
  24. package/ketchup-plan.md +5 -6
  25. package/package.json +4 -4
  26. package/src/codegen/extract/imports.ts +0 -8
  27. package/src/codegen/extract/type-helpers.specs.ts +23 -0
  28. package/src/codegen/extract/type-helpers.ts +4 -0
  29. package/src/codegen/scaffoldFromSchema.ts +7 -3
  30. package/src/codegen/templates/command/decide.specs.ts.ejs +3 -3
  31. package/src/codegen/templates/command/decide.ts.ejs +1 -1
  32. package/src/codegen/templates/query/events.specs.ts +112 -0
  33. package/src/codegen/templates/query/events.ts.ejs +17 -0
  34. package/src/codegen/templates/query/projection.specs.specs.ts +125 -0
  35. package/src/codegen/templates/query/projection.specs.ts.ejs +2 -2
  36. package/src/codegen/templates/react/react.specs.ts.ejs +2 -2
package/ketchup-plan.md CHANGED
@@ -1,11 +1,10 @@
1
- # Ketchup Plan: Fix Outstanding Issues from Typical Server Generation
1
+ # Ketchup Plan: Fix Unescaped Quotes in EJS Template String Literals
2
2
 
3
3
  ## TODO
4
4
 
5
- (none)
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
6
9
 
7
10
  ## DONE
8
-
9
- - [x] Burst 1: Align ReactorLike return type with MessageHandlerResult (3b1217fc)
10
- - [x] Burst 2: Rename `then` → `thenSends` to eliminate thenable risk (c65fa28d)
11
- - [x] Burst 3: Prefix unused aggregateStream state var with `_` (04fd4cbb)
package/package.json CHANGED
@@ -32,8 +32,8 @@
32
32
  "uuid": "^13.0.0",
33
33
  "web-streams-polyfill": "^4.1.0",
34
34
  "zod": "^3.22.4",
35
- "@auto-engineer/narrative": "1.129.0",
36
- "@auto-engineer/message-bus": "1.129.0"
35
+ "@auto-engineer/message-bus": "1.131.0",
36
+ "@auto-engineer/narrative": "1.131.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.129.0"
47
+ "@auto-engineer/cli": "1.131.0"
48
48
  },
49
- "version": "1.129.0",
49
+ "version": "1.131.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",
@@ -51,14 +51,6 @@ export function groupEventImports(context: CrossSliceImportContext): ImportGroup
51
51
  }));
52
52
  }
53
53
 
54
- /**
55
- * Filters events to only include those from the current slice (source === 'then').
56
- * Used for generating local event definitions.
57
- */
58
- export function getLocalEvents(events: Message[]): Message[] {
59
- return events.filter((event) => event.source === 'then');
60
- }
61
-
62
54
  /**
63
55
  * Extracts all unique event types from a list of events.
64
56
  */
@@ -2,6 +2,7 @@ import { parseInlineObjectFields } from '@auto-engineer/narrative';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import {
4
4
  createFieldUsesJSON,
5
+ escapeJsString,
5
6
  findPrimitiveLinkingField,
6
7
  isPrimitiveTsType,
7
8
  isValidTsIdentifier,
@@ -172,6 +173,28 @@ describe('findPrimitiveLinkingField', () => {
172
173
  });
173
174
  });
174
175
 
176
+ describe('escapeJsString', () => {
177
+ it('escapes single quotes', () => {
178
+ expect(escapeJsString("user's workouts")).toBe("user\\'s workouts");
179
+ });
180
+
181
+ it('escapes backslashes', () => {
182
+ expect(escapeJsString('path\\to\\file')).toBe('path\\\\to\\\\file');
183
+ });
184
+
185
+ it('escapes newlines and carriage returns', () => {
186
+ expect(escapeJsString('line1\nline2\rline3')).toBe('line1\\nline2\\rline3');
187
+ });
188
+
189
+ it('escapes backslashes before single quotes', () => {
190
+ expect(escapeJsString("it\\'s")).toBe("it\\\\\\'s");
191
+ });
192
+
193
+ it('returns plain strings unchanged', () => {
194
+ expect(escapeJsString('no special chars')).toBe('no special chars');
195
+ });
196
+ });
197
+
175
198
  describe('createFieldUsesJSON', () => {
176
199
  const stubGraphqlType = (ts: string): string => {
177
200
  if (ts === 'unknown' || ts === 'any' || ts === 'object') return 'GraphQLJSON';
@@ -105,6 +105,10 @@ export function isPrimitiveTsType(tsType: string): boolean {
105
105
  return ['string', 'number', 'boolean', 'Date', 'ID'].includes(baseTs(tsType));
106
106
  }
107
107
 
108
+ export function escapeJsString(value: string): string {
109
+ return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
110
+ }
111
+
108
112
  export function findPrimitiveLinkingField(
109
113
  stateFields: Array<{ name: string; type?: string; tsType?: string }>,
110
114
  eventFields: Array<{ name: string; type?: string; tsType?: string }>,
@@ -32,11 +32,11 @@ import {
32
32
  createFieldUsesFloat,
33
33
  createFieldUsesJSON,
34
34
  createIsEnumType,
35
+ escapeJsString,
35
36
  extractMessagesFromSpecs,
36
37
  extractProjectionName,
37
38
  findPrimitiveLinkingField,
38
39
  getAllEventTypes,
39
- getLocalEvents,
40
40
  groupEventImports,
41
41
  isPrimitiveTsType,
42
42
  isValidTsIdentifier,
@@ -83,7 +83,7 @@ const defaultFilesByType: Record<string, string[]> = {
83
83
  'decide.specs.ts.ejs',
84
84
  'register.ts.ejs',
85
85
  ],
86
- query: ['projection.ts.ejs', 'state.ts.ejs', 'projection.specs.ts.ejs', 'query.resolver.ts.ejs'],
86
+ query: ['events.ts.ejs', 'projection.ts.ejs', 'state.ts.ejs', 'projection.specs.ts.ejs', 'query.resolver.ts.ejs'],
87
87
  react: ['events.ts.ejs', 'react.ts.ejs', 'react.specs.ts.ejs', 'register.ts.ejs'],
88
88
  };
89
89
 
@@ -402,6 +402,7 @@ async function renderTemplate(
402
402
 
403
403
  const result = await template({
404
404
  ...data,
405
+ escapeJsString,
405
406
  pascalCase,
406
407
  toKebabCase,
407
408
  camelCase,
@@ -665,7 +666,10 @@ async function prepareTemplateData(
665
666
  const eventImportGroups = groupEventImports({ currentSliceName: slice.name, currentFlowName: flow.name, events });
666
667
  const allEventTypesArray = getAllEventTypes(events);
667
668
  const allEventTypes = createEventUnionType(events);
668
- const localEvents = getLocalEvents(events);
669
+ const selfImportedTypes = new Set(
670
+ eventImportGroups.filter((g) => g.importPath === './events').flatMap((g) => g.eventTypes),
671
+ );
672
+ const localEvents = events.filter((e) => selfImportedTypes.has(e.type));
669
673
 
670
674
  const messagesByName = new Map((allMessages ?? []).map((m) => [m.name, m]));
671
675
  const targetName = 'server' in slice ? slice.server?.data?.items?.[0]?.target?.name : undefined;
@@ -110,7 +110,7 @@ import type { <%= group.eventTypes.join(', ') %> } from '<%= group.importPath %>
110
110
  <% } -%>
111
111
  import type { <%= Object.keys(commandSchemasByName).join(', ') %> } from './commands';
112
112
  <% for (const [ruleDescription, ruleGwts] of ruleGroups.entries()) { %>
113
- describe('<%= ruleDescription %>', () => {
113
+ describe('<%= escapeJsString(ruleDescription) %>', () => {
114
114
 
115
115
  type Events = <%= uniqueEventTypes.length > 0 ? uniqueEventTypes.join(' | ') : 'never' %>;
116
116
 
@@ -134,7 +134,7 @@ describe('<%= ruleDescription %>', () => {
134
134
  ? `should throw ${errorResult.errorType} when ${gwt.failingFields?.join(', ') || 'invalid input'}`
135
135
  : `should emit ${eventResults.map(e => e.eventRef).join(', ')} for ${commandName}`);
136
136
  %>
137
- it('<%= testDescription %>', () => {
137
+ it('<%= escapeJsString(testDescription) %>', () => {
138
138
  given([
139
139
  <%_ if (gwt.given && gwt.given.length) { _%>
140
140
  <%- gwt.given.map(g => `{
@@ -158,7 +158,7 @@ describe('<%= ruleDescription %>', () => {
158
158
  metadata: { now: <%= derivedDate ? `new Date('${derivedDate}')` : 'new Date()' %> },
159
159
  })
160
160
  <% if (errorResult) { %>
161
- .thenThrows((err) => err instanceof <%= errorResult.errorType %> && err.message === '<%= errorResult.message || '' %>');
161
+ .thenThrows((err) => err instanceof <%= errorResult.errorType %> && err.message === '<%= escapeJsString(errorResult.message || '') %>');
162
162
  <% } else { %>
163
163
 
164
164
  .then(expectEvents(
@@ -95,7 +95,7 @@ if (error && gwt.failingFields?.length) {
95
95
  const condition = gwt.failingFields.map(field => `command.data.${field} === ''`).join(' || ');
96
96
  -%>
97
97
  if (<%- condition %>) {
98
- throw new <%= error.errorType %>('<%- error.message ?? 'Validation failed' %>');
98
+ throw new <%= error.errorType %>('<%= escapeJsString(error.message ?? "Validation failed") %>');
99
99
  }
100
100
  <% } } -%>
101
101
 
@@ -0,0 +1,112 @@
1
+ import type { Model } from '@auto-engineer/narrative';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { generateScaffoldFilePlans } from '../../scaffoldFromSchema';
4
+
5
+ describe('query events.ts.ejs', () => {
6
+ it('generates events.ts for orphan events not produced by any command slice', async () => {
7
+ const spec: Model = {
8
+ variant: 'specs',
9
+ narratives: [
10
+ {
11
+ name: 'appointment-flow',
12
+ slices: [
13
+ {
14
+ type: 'query',
15
+ name: 'view-appointments',
16
+ stream: 'appointments',
17
+ client: { specs: [] },
18
+ server: {
19
+ description: 'projection for booked appointments',
20
+ data: {
21
+ items: [
22
+ {
23
+ target: { type: 'State', name: 'Appointment' },
24
+ origin: {
25
+ type: 'projection',
26
+ name: 'AppointmentsProjection',
27
+ idField: 'appointmentId',
28
+ },
29
+ },
30
+ ],
31
+ },
32
+ specs: [
33
+ {
34
+ type: 'gherkin',
35
+ feature: 'View appointments query',
36
+ rules: [
37
+ {
38
+ name: 'Should project appointments',
39
+ examples: [
40
+ {
41
+ name: 'Appointment booked shows in list',
42
+ steps: [
43
+ {
44
+ keyword: 'When',
45
+ text: 'AppointmentBooked',
46
+ docString: {
47
+ appointmentId: 'appt_1',
48
+ date: '2024-06-15',
49
+ },
50
+ },
51
+ {
52
+ keyword: 'Then',
53
+ text: 'Appointment',
54
+ docString: {
55
+ appointmentId: 'appt_1',
56
+ date: '2024-06-15',
57
+ },
58
+ },
59
+ ],
60
+ },
61
+ ],
62
+ },
63
+ ],
64
+ },
65
+ ],
66
+ },
67
+ },
68
+ ],
69
+ },
70
+ ],
71
+ messages: [
72
+ {
73
+ type: 'event',
74
+ name: 'AppointmentBooked',
75
+ source: 'internal',
76
+ fields: [
77
+ { name: 'appointmentId', type: 'string', required: true },
78
+ { name: 'date', type: 'string', required: true },
79
+ ],
80
+ },
81
+ {
82
+ type: 'state',
83
+ name: 'Appointment',
84
+ fields: [
85
+ { name: 'appointmentId', type: 'string', required: true },
86
+ { name: 'date', type: 'string', required: true },
87
+ ],
88
+ },
89
+ ],
90
+ };
91
+
92
+ const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
93
+
94
+ const eventFile = plans.find((p) => p.outputPath.endsWith('view-appointments/events.ts'));
95
+ expect(eventFile).toBeDefined();
96
+ expect(eventFile?.contents).toMatchInlineSnapshot(`
97
+ "import type { Event } from '@event-driven-io/emmett';
98
+
99
+ export type AppointmentBooked = Event<
100
+ 'AppointmentBooked',
101
+ {
102
+ appointmentId: string;
103
+ date: string;
104
+ }
105
+ >;
106
+ "
107
+ `);
108
+
109
+ const projectionFile = plans.find((p) => p.outputPath.endsWith('view-appointments/projection.ts'));
110
+ expect(projectionFile?.contents).toContain("import type { AppointmentBooked } from './events'");
111
+ });
112
+ });
@@ -0,0 +1,17 @@
1
+ <%
2
+ const enumList = collectEnumNames(localEvents.flatMap(e => e.fields));
3
+ %><% if (localEvents.length) { -%>
4
+ import type { Event } from '@event-driven-io/emmett';
5
+ <% if (enumList.length > 0) { %>import { <%= enumList.join(', ') %> } from '../../../shared';
6
+ <% } %>
7
+ <% for (const event of localEvents) { -%>
8
+ export type <%= pascalCase(event.type) %> = Event<
9
+ '<%= event.type %>',
10
+ {
11
+ <% for (const field of event.fields) { -%>
12
+ <%- field.name %>: <%- toTsFieldType(field.tsType) %>;
13
+ <% } -%>
14
+ }
15
+ >;
16
+ <% } -%>
17
+ <% } -%>
@@ -2616,4 +2616,129 @@ describe('projection.specs.ts.ejs', () => {
2616
2616
  "
2617
2617
  `);
2618
2618
  });
2619
+
2620
+ it('should escape apostrophes in rule names and test descriptions', async () => {
2621
+ const spec: SpecsSchema = {
2622
+ variant: 'specs',
2623
+ narratives: [
2624
+ {
2625
+ name: 'workout-flow',
2626
+ slices: [
2627
+ {
2628
+ type: 'command',
2629
+ name: 'log-workout',
2630
+ stream: 'workouts-${memberId}',
2631
+ client: { specs: [] },
2632
+ server: {
2633
+ description: '',
2634
+ specs: [
2635
+ {
2636
+ type: 'gherkin',
2637
+ feature: 'Log workout',
2638
+ rules: [
2639
+ {
2640
+ name: 'Should log',
2641
+ examples: [
2642
+ {
2643
+ name: 'Logs workout',
2644
+ steps: [
2645
+ {
2646
+ keyword: 'When',
2647
+ text: 'LogWorkout',
2648
+ docString: { memberId: 'mem_001', caloriesBurned: 250 },
2649
+ },
2650
+ {
2651
+ keyword: 'Then',
2652
+ text: 'WorkoutRecorded',
2653
+ docString: { memberId: 'mem_001', caloriesBurned: 250 },
2654
+ },
2655
+ ],
2656
+ },
2657
+ ],
2658
+ },
2659
+ ],
2660
+ },
2661
+ ],
2662
+ },
2663
+ },
2664
+ {
2665
+ type: 'query',
2666
+ name: 'view-user-workouts',
2667
+ stream: 'workouts',
2668
+ client: { specs: [] },
2669
+ server: {
2670
+ description: '',
2671
+ data: {
2672
+ items: [
2673
+ {
2674
+ target: { type: 'State', name: 'UserWorkouts' },
2675
+ origin: { type: 'projection', name: 'UserWorkoutsProjection', idField: 'memberId' },
2676
+ },
2677
+ ],
2678
+ },
2679
+ specs: [
2680
+ {
2681
+ type: 'gherkin',
2682
+ feature: "View user's workouts",
2683
+ rules: [
2684
+ {
2685
+ name: "List of user's workouts",
2686
+ examples: [
2687
+ {
2688
+ name: "User's workout history",
2689
+ steps: [
2690
+ {
2691
+ keyword: 'When',
2692
+ text: 'WorkoutRecorded',
2693
+ docString: { memberId: 'mem_001', caloriesBurned: 300 },
2694
+ },
2695
+ {
2696
+ keyword: 'Then',
2697
+ text: 'UserWorkouts',
2698
+ docString: { totalCalories: 300 },
2699
+ },
2700
+ ],
2701
+ },
2702
+ ],
2703
+ },
2704
+ ],
2705
+ },
2706
+ ],
2707
+ },
2708
+ },
2709
+ ],
2710
+ },
2711
+ ],
2712
+ messages: [
2713
+ {
2714
+ type: 'command',
2715
+ name: 'LogWorkout',
2716
+ fields: [
2717
+ { name: 'memberId', type: 'string', required: true },
2718
+ { name: 'caloriesBurned', type: 'number', required: true },
2719
+ ],
2720
+ },
2721
+ {
2722
+ type: 'event',
2723
+ name: 'WorkoutRecorded',
2724
+ source: 'internal',
2725
+ fields: [
2726
+ { name: 'memberId', type: 'string', required: true },
2727
+ { name: 'caloriesBurned', type: 'number', required: true },
2728
+ ],
2729
+ },
2730
+ {
2731
+ type: 'state',
2732
+ name: 'UserWorkouts',
2733
+ fields: [{ name: 'totalCalories', type: 'number', required: true }],
2734
+ },
2735
+ ],
2736
+ } as SpecsSchema;
2737
+
2738
+ const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
2739
+ const specFile = plans.find((p) => p.outputPath.endsWith('view-user-workouts/projection.specs.ts'));
2740
+
2741
+ expect(specFile?.contents).toContain('describe("List of user\'s workouts"');
2742
+ expect(specFile?.contents).toContain('it("User\'s workout history"');
2743
+ });
2619
2744
  });
@@ -172,7 +172,7 @@ import { <%= TargetType %> } from './state';
172
172
  type ProjectionEvent = <%= uniqueEventTypes.length ? uniqueEventTypes.join(' | ') : 'never' %>;
173
173
 
174
174
  <% for (const [ruleDescription, ruleTests] of ruleGroups.entries()) { %>
175
- describe('<%= ruleDescription %>', () => {
175
+ describe('<%= escapeJsString(ruleDescription) %>', () => {
176
176
  let given: InMemoryProjectionSpec<ProjectionEvent>;
177
177
 
178
178
  beforeEach(() => {
@@ -199,7 +199,7 @@ describe('<%= ruleDescription %>', () => {
199
199
  : 'should handle events correctly');
200
200
  _%>
201
201
 
202
- it('<%= description %>', () =>
202
+ it('<%= escapeJsString(description) %>', () =>
203
203
  given([<% if (givenEvents.length > 0) {
204
204
  for (const evt of givenEvents) {
205
205
  if (!evt.eventRef || evt.eventRef === '') continue;
@@ -85,7 +85,7 @@ type ReactorEvent = <%= allEventTypes.length ? allEventTypes.join(' | ') : 'neve
85
85
  type ReactorCommand = <%= allCommandTypes.length ? allCommandTypes.join(' | ') : 'never' %>;
86
86
 
87
87
  <% for (const [ruleDescription, ruleTests] of ruleGroups.entries()) { %>
88
- describe('<%= ruleDescription %>', () => {
88
+ describe('<%= escapeJsString(ruleDescription) %>', () => {
89
89
  let eventStore: InMemoryEventStore;
90
90
  let given: ReactorSpecification<ReactorEvent, ReactorCommand, ReactorContext>;
91
91
  let messageBus: CommandSender;
@@ -114,7 +114,7 @@ describe('<%= ruleDescription %>', () => {
114
114
  const description = testCase.description ||
115
115
  `should send ${thenCommands.map(c => c.commandRef).join(', ')} when ${exampleEvent.eventRef} is received`;
116
116
  %>
117
- it('<%= description %>', async () => {
117
+ it('<%= escapeJsString(description) %>', async () => {
118
118
  <%
119
119
  const givenStates = testCase.given.filter(g =>
120
120
  messages.some(m => m.type === 'state' && m.name === g.eventRef)