@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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +6 -6
- package/.turbo/turbo-type-check.log +1 -1
- package/CHANGELOG.md +39 -0
- package/dist/src/codegen/extract/data-sink.d.ts +1 -0
- package/dist/src/codegen/extract/data-sink.d.ts.map +1 -1
- package/dist/src/codegen/extract/data-sink.js +3 -0
- package/dist/src/codegen/extract/data-sink.js.map +1 -1
- package/dist/src/codegen/extract/messages.d.ts +1 -0
- package/dist/src/codegen/extract/messages.d.ts.map +1 -1
- package/dist/src/codegen/extract/messages.js +1 -1
- package/dist/src/codegen/extract/messages.js.map +1 -1
- package/dist/src/codegen/extract/sibling-events.d.ts +8 -0
- package/dist/src/codegen/extract/sibling-events.d.ts.map +1 -0
- package/dist/src/codegen/extract/sibling-events.js +58 -0
- package/dist/src/codegen/extract/sibling-events.js.map +1 -0
- package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
- package/dist/src/codegen/scaffoldFromSchema.js +37 -2
- package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
- package/dist/src/codegen/templates/command/decide.specs.specs.ts +125 -0
- package/dist/src/codegen/templates/command/decide.specs.ts +258 -3
- package/dist/src/codegen/templates/command/decide.specs.ts.ejs +42 -14
- package/dist/src/codegen/templates/command/decide.ts.ejs +63 -16
- package/dist/src/codegen/templates/command/evolve.specs.ts +217 -39
- package/dist/src/codegen/templates/command/evolve.ts.ejs +7 -7
- package/dist/src/codegen/templates/command/handle.specs.ts +7 -4
- package/dist/src/codegen/templates/command/handle.ts.ejs +17 -5
- package/dist/src/codegen/templates/command/state.specs.ts +125 -0
- package/dist/src/codegen/templates/command/state.ts.ejs +10 -2
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/ketchup-plan.md +3 -3
- package/package.json +4 -4
- package/src/codegen/extract/data-sink.specs.ts +23 -1
- package/src/codegen/extract/data-sink.ts +4 -0
- package/src/codegen/extract/messages.ts +1 -1
- package/src/codegen/extract/sibling-events.specs.ts +206 -0
- package/src/codegen/extract/sibling-events.ts +76 -0
- package/src/codegen/ketchup-plan.md +12 -0
- package/src/codegen/scaffoldFromSchema.ts +50 -0
- package/src/codegen/templates/command/decide.specs.specs.ts +125 -0
- package/src/codegen/templates/command/decide.specs.ts +258 -3
- package/src/codegen/templates/command/decide.specs.ts.ejs +42 -14
- package/src/codegen/templates/command/decide.ts.ejs +63 -16
- package/src/codegen/templates/command/evolve.specs.ts +217 -39
- package/src/codegen/templates/command/evolve.ts.ejs +7 -7
- package/src/codegen/templates/command/handle.specs.ts +7 -4
- package/src/codegen/templates/command/handle.ts.ejs +17 -5
- package/src/codegen/templates/command/state.specs.ts +125 -0
- 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<%=
|
|
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 =>
|
|
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 => !
|
|
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 <%=
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
128
|
-
(s.given || []).filter(g =>
|
|
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
|
-
<%
|
|
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
|
|
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 (
|
|
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<%=
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 (
|
|
1
|
+
<% if (allStreamEvents.length) { -%>
|
|
2
2
|
import type { State } from './state';
|
|
3
|
-
<% for (const group of
|
|
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 doesn
|
|
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:
|
|
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: <%=
|
|
34
|
+
event: <%= allStreamEvents.map(e => pascalCase(e.type)).join(' | ') %>
|
|
35
35
|
): State => {
|
|
36
36
|
switch (event.type) {
|
|
37
|
-
<%
|
|
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:
|
|
327
|
-
decide(command, state
|
|
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
|
|
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 '${
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
};
|