@auto-engineer/server-generator-apollo-emmett 1.150.0 → 1.152.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 (43) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +5 -5
  3. package/.turbo/turbo-type-check.log +1 -1
  4. package/CHANGELOG.md +74 -0
  5. package/dist/src/codegen/extract/type-helpers.d.ts.map +1 -1
  6. package/dist/src/codegen/extract/type-helpers.js +3 -2
  7. package/dist/src/codegen/extract/type-helpers.js.map +1 -1
  8. package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
  9. package/dist/src/codegen/scaffoldFromSchema.js +44 -6
  10. package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
  11. package/dist/src/codegen/templateHelpers.d.ts +1 -0
  12. package/dist/src/codegen/templateHelpers.d.ts.map +1 -1
  13. package/dist/src/codegen/templateHelpers.js +12 -0
  14. package/dist/src/codegen/templateHelpers.js.map +1 -1
  15. package/dist/src/codegen/templates/command/decide.specs.specs.ts +688 -11
  16. package/dist/src/codegen/templates/command/decide.specs.ts.ejs +90 -3
  17. package/dist/src/codegen/templates/query/projection.specs.specs.ts +6 -3
  18. package/dist/src/codegen/templates/query/projection.specs.ts.ejs +3 -8
  19. package/dist/src/codegen/templates/query/query.resolver.specs.ts +63 -0
  20. package/dist/src/codegen/templates/query/query.resolver.ts.ejs +8 -2
  21. package/dist/src/codegen/templates/react/react.specs.ts.ejs +19 -5
  22. package/dist/src/codegen/templates/react/react.ts.ejs +71 -19
  23. package/dist/src/codegen/templates/react/react.ts.specs.ts +139 -0
  24. package/dist/src/codegen/templates/react/register.ts.ejs +34 -9
  25. package/dist/tsconfig.tsbuildinfo +1 -1
  26. package/ketchup-plan.md +5 -5
  27. package/package.json +4 -4
  28. package/src/codegen/extract/type-helpers.specs.ts +21 -0
  29. package/src/codegen/extract/type-helpers.ts +3 -2
  30. package/src/codegen/scaffoldFromSchema.filter.specs.ts +110 -1
  31. package/src/codegen/scaffoldFromSchema.ts +47 -8
  32. package/src/codegen/templateHelpers.specs.ts +21 -1
  33. package/src/codegen/templateHelpers.ts +9 -0
  34. package/src/codegen/templates/command/decide.specs.specs.ts +688 -11
  35. package/src/codegen/templates/command/decide.specs.ts.ejs +90 -3
  36. package/src/codegen/templates/query/projection.specs.specs.ts +6 -3
  37. package/src/codegen/templates/query/projection.specs.ts.ejs +3 -8
  38. package/src/codegen/templates/query/query.resolver.specs.ts +63 -0
  39. package/src/codegen/templates/query/query.resolver.ts.ejs +8 -2
  40. package/src/codegen/templates/react/react.specs.ts.ejs +19 -5
  41. package/src/codegen/templates/react/react.ts.ejs +71 -19
  42. package/src/codegen/templates/react/react.ts.specs.ts +139 -0
  43. package/src/codegen/templates/react/register.ts.ejs +34 -9
@@ -54,8 +54,33 @@ for (const [importPath, eventTypes] of testEventsByPath.entries()) {
54
54
 
55
55
  const uniqueEventTypes = Array.from(new Set(allEvents.map(e => e?.type).filter(Boolean))).sort();
56
56
 
57
+ const successScenarios = [];
58
+ const seenCommandEvents = new Set();
59
+ for (const commandName in gwtMapping) {
60
+ const cases = gwtMapping[commandName];
61
+ const schema = commandSchemasByName[commandName];
62
+ const cmdFieldNames = new Set(schema?.fields?.map(f => f.name) || []);
63
+ for (const gwt of cases) {
64
+ const eventResults = gwt.then.filter(t => 'eventRef' in t);
65
+ const errorResult = gwt.then.find(t => 'errorType' in t);
66
+ if (errorResult) continue;
67
+ const givenEventRefs = (gwt.given || []).filter(g => events.some(ev => ev.type === g.eventRef));
68
+ for (const e of eventResults) {
69
+ const dedupeKey = `${commandName}::${e.eventRef}`;
70
+ if (seenCommandEvents.has(dedupeKey)) continue;
71
+ seenCommandEvents.add(dedupeKey);
72
+ const evtSchema = events.find(evt => evt.type === e.eventRef);
73
+ const evtFields = evtSchema?.fields || [];
74
+ const nonCmdFields = evtFields.filter(f => !cmdFieldNames.has(f.name));
75
+ if (nonCmdFields.length > 0) {
76
+ successScenarios.push({ commandName, gwt, eventRef: e.eventRef, evtSchema, nonCmdFields, cmdFieldNames, thenExampleData: e.exampleData || {}, givenEventRefs });
77
+ }
78
+ }
79
+ }
80
+ }
81
+ const hasFieldCompletenessTests = successScenarios.length > 0;
57
82
  _%>
58
- import { describe, it } from 'vitest';
83
+ import { describe, <% if (hasFieldCompletenessTests) { %>expect, <% } %>it } from 'vitest';
59
84
  import { DeciderSpecification } from '@event-driven-io/emmett';
60
85
  import { decide } from './decide';
61
86
  import { evolve } from './evolve';
@@ -108,8 +133,8 @@ describe('<%= escapeJsString(ruleDescription) %>', () => {
108
133
  -%>
109
134
  data: <%- formatDataObject(filteredCmdData, schema) %>,
110
135
  <%
111
- const { date: derivedDate, fields: derivedDateFieldNames } = findDerivedDateInfo(eventResults, commandFieldNames, gwt.given);
112
- const keepFieldNames = buildKeepFieldNames(eventResults, commandFieldNames, derivedDateFieldNames, gwt.given);
136
+ const { date: derivedDate, fields: derivedDateFieldNames } = findDerivedDateInfo(eventResults, commandFieldNames, givenEvents);
137
+ const keepFieldNames = buildKeepFieldNames(eventResults, commandFieldNames, derivedDateFieldNames, givenEvents);
113
138
  -%>
114
139
  metadata: { now: <%= derivedDate ? `new Date('${derivedDate}')` : 'new Date()' %> },
115
140
  })
@@ -133,4 +158,66 @@ describe('<%= escapeJsString(ruleDescription) %>', () => {
133
158
  });
134
159
  <% } %>
135
160
  });
161
+ <% } %>
162
+ <% if (hasFieldCompletenessTests) {
163
+ const scenariosByCommand = new Map();
164
+ for (const s of successScenarios) {
165
+ if (!scenariosByCommand.has(s.commandName)) scenariosByCommand.set(s.commandName, []);
166
+ scenariosByCommand.get(s.commandName).push(s);
167
+ }
168
+ %>
169
+ describe('field completeness', () => {
170
+ <% for (const [cmdName, scenarios] of scenariosByCommand.entries()) {
171
+ const firstScenario = scenarios[0];
172
+ const gwt = firstScenario.gwt;
173
+ const schema = commandSchemasByName[cmdName];
174
+ const givenEvents = (gwt.given || []).filter(g => events.some(e => e.type === g.eventRef));
175
+ const example = gwt.when;
176
+ const cmdFieldNames = firstScenario.cmdFieldNames;
177
+ const filteredCmdData = Object.fromEntries(
178
+ Object.entries(example.exampleData || {}).filter(([key]) => cmdFieldNames.has(key))
179
+ );
180
+ %>
181
+ it('should include all required fields in <%= scenarios.map(s => s.eventRef).join(', ') %>', () => {
182
+ <%_ if (givenEvents.length) { _%>
183
+ 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 ') %>
188
+ ].reduce((s, e) => evolve(s, e), initialState());
189
+ <%_ } else { _%>
190
+ const state = initialState();
191
+ <%_ } _%>
192
+ const result = decide(
193
+ { type: '<%= example.commandRef %>', data: <%- formatDataObject(filteredCmdData, schema) %>, metadata: { now: new Date() } },
194
+ state,
195
+ );
196
+ const evtArray = Array.isArray(result) ? result : [result];
197
+ <% for (let i = 0; i < scenarios.length; i++) {
198
+ const s = scenarios[i];
199
+ const allFields = s.evtSchema?.fields || [];
200
+ %>
201
+ expect(evtArray[<%= i %>]).toMatchObject({
202
+ type: '<%= s.eventRef %>',
203
+ data: expect.objectContaining({
204
+ <% for (const f of allFields) {
205
+ const isCommandField = s.cmdFieldNames.has(f.name);
206
+ const modelValue = s.thenExampleData[f.name];
207
+ const isTraceableToGivenEvent = modelValue !== undefined
208
+ && isKeyTraceable(f.name, modelValue, s.givenEventRefs);
209
+ const hasModelValue = !isCommandField && isTraceableToGivenEvent;
210
+ -%>
211
+ <% if (hasModelValue) { -%>
212
+ <%= f.name %>: <%- formatTsValue(modelValue, f.tsType ?? f.type ?? 'string') %>,
213
+ <% } else { -%>
214
+ <%= f.name %>: expect.any(<%= jsConstructorForType(f.tsType ?? f.type ?? 'string') %>),
215
+ <% } -%>
216
+ <% } -%>
217
+ }),
218
+ });
219
+ <% } %>
220
+ });
221
+ <% } %>
222
+ });
136
223
  <% } %>
@@ -1202,7 +1202,8 @@ describe('projection.specs.ts.ejs', () => {
1202
1202
  .collection<WorkoutHistory>('WorkoutHistoryProjection')
1203
1203
  .findOne((doc) => doc.memberId === 'mem_001');
1204
1204
 
1205
- const expected = {
1205
+ const expected: WorkoutHistory = {
1206
+ memberId: 'mem_001',
1206
1207
  totalCalories: 250,
1207
1208
  };
1208
1209
 
@@ -1213,7 +1214,7 @@ describe('projection.specs.ts.ejs', () => {
1213
1214
  `);
1214
1215
  });
1215
1216
 
1216
- it('should exclude query arg fields that differ from projection-computed values', async () => {
1217
+ it('should include all Then fields in query action assertions', async () => {
1217
1218
  const spec: SpecsSchema = {
1218
1219
  variant: 'specs',
1219
1220
  scenes: [
@@ -1417,11 +1418,13 @@ describe('projection.specs.ts.ejs', () => {
1417
1418
  .collection<FartHistory>('FartHistoryProjection')
1418
1419
  .findOne((doc) => doc.userId === 'usr_123');
1419
1420
 
1420
- const expected = {
1421
+ const expected: FartHistory = {
1422
+ userId: 'usr_123',
1421
1423
  farts: [
1422
1424
  { fartId: 'fart_456', intensity: 7 },
1423
1425
  { fartId: 'fart_789', intensity: 9 },
1424
1426
  ],
1427
+ minIntensity: 6,
1425
1428
  };
1426
1429
 
1427
1430
  expect(document).toMatchObject(expected);
@@ -282,15 +282,10 @@ if (isCompositeKey) {
282
282
 
283
283
  <%
284
284
  const stateKeys = Object.keys(expectedState.exampleData || {});
285
- const queryArgs = isQueryActionTest
286
- ? (testCase.when?.args
287
- || (Array.isArray(testCase.when) && testCase.when[0]?.exampleData)
288
- || {})
289
- : {};
290
- const queryArgKeys = new Set(Object.keys(queryArgs));
291
- const assertionKeys = stateKeys.filter(k => !queryArgKeys.has(k));
285
+ const assertionKeys = stateKeys;
292
286
  const stateMessage = messages.find(m => m.name === targetName);
293
- const isPartial = assertionKeys.length < stateKeys.length;
287
+ const stateFieldSet = new Set((stateMessage?.fields || []).map(f => f.name));
288
+ const isPartial = assertionKeys.length !== stateFieldSet.size || assertionKeys.some(k => !stateFieldSet.has(k));
294
289
  -%>
295
290
  const expected<%= isPartial ? '' : `: ${TargetType}` %> = {
296
291
  <% for (let i = 0; i < assertionKeys.length; i++) {
@@ -1478,4 +1478,67 @@ describe('query.resolver.ts.ejs', () => {
1478
1478
  expect(resolverFile?.contents).toContain('item.numBedrooms < minBedrooms');
1479
1479
  expect(resolverFile?.contents).toContain('item.pricePerNight > maxPrice');
1480
1480
  });
1481
+
1482
+ it('should generate Date-aware comparison for Date-typed filter fields', async () => {
1483
+ const spec: SpecsSchema = {
1484
+ variant: 'specs',
1485
+ scenes: [
1486
+ {
1487
+ name: 'booking-scene',
1488
+ moments: [
1489
+ {
1490
+ type: 'query',
1491
+ name: 'search-bookings',
1492
+ request: `
1493
+ query SearchBookings($checkIn: String) {
1494
+ searchBookings(checkIn: $checkIn) {
1495
+ bookingId
1496
+ checkIn
1497
+ guestName
1498
+ }
1499
+ }
1500
+ `,
1501
+ client: { specs: [] },
1502
+ server: {
1503
+ description: '',
1504
+ data: {
1505
+ items: [
1506
+ {
1507
+ origin: {
1508
+ type: 'projection',
1509
+ idField: 'bookingId',
1510
+ name: 'BookingsProjection',
1511
+ },
1512
+ target: {
1513
+ type: 'State',
1514
+ name: 'BookingView',
1515
+ },
1516
+ },
1517
+ ],
1518
+ },
1519
+ specs: [],
1520
+ },
1521
+ },
1522
+ ],
1523
+ },
1524
+ ],
1525
+ messages: [
1526
+ {
1527
+ type: 'state',
1528
+ name: 'BookingView',
1529
+ fields: [
1530
+ { name: 'bookingId', type: 'string', required: true },
1531
+ { name: 'checkIn', type: 'Date', required: true },
1532
+ { name: 'guestName', type: 'string', required: true },
1533
+ ],
1534
+ },
1535
+ ],
1536
+ };
1537
+
1538
+ const { plans } = await generateScaffoldFilePlans(spec.scenes, spec.messages, undefined, 'src/domain/narratives');
1539
+ const resolverFile = plans.find((p) => p.outputPath.endsWith('query.resolver.ts'));
1540
+
1541
+ expect(resolverFile?.contents).toContain('new Date(item.checkIn).getTime()');
1542
+ expect(resolverFile?.contents).toContain('new Date(checkIn).getTime()');
1543
+ });
1481
1544
  });
@@ -195,9 +195,15 @@ return model.<%= isIdLookup ? 'findOne' : 'find' %>((<%= hasMatchingArgs ? 'item
195
195
  const mapping = argFieldMap[arg.name];
196
196
  const mappedField = mapping ? mapping.field : (stateFieldNames.has(arg.name) ? arg.name : null);
197
197
  const op = mapping ? negatedOp[mapping.operator] : '!==';
198
- if (mappedField) { %>
198
+ if (mappedField) {
199
+ const stateField = messageFields.find(f => f.name === mappedField);
200
+ const isDateField = stateField && fieldUsesDate(stateField.type ?? 'string');
201
+ if (isDateField) { %>
202
+ if (<%= arg.name %> !== undefined && new Date(item.<%= mappedField %>).getTime() <%= op %> new Date(<%= arg.name %>).getTime()) return false;
203
+ <% } else { %>
199
204
  if (<%= arg.name %> !== undefined && item.<%= mappedField %> <%= op %> <%= arg.name %>) return false;
200
- <% } else { %>
205
+ <% }
206
+ } else { %>
201
207
  // NOTE: '<%= arg.name %>' has no matching field on the state type — add custom filter logic if needed.
202
208
  <% }
203
209
  }
@@ -119,18 +119,32 @@ describe('<%= escapeJsString(ruleDescription) %>', () => {
119
119
  const givenStates = testCase.given.filter(g =>
120
120
  messages.some(m => m.type === 'state' && m.name === g.eventRef)
121
121
  );
122
+ const targetCommand = thenCommands[0]?.commandRef;
123
+ const matchedPair = eventCommandPairs.find(p => p.commandType === targetCommand);
124
+ const pairStreamPattern = matchedPair?.streamPattern;
122
125
  for (const gs of givenStates) {
123
126
  const stateSchema = states.find(s => s.type === gs.eventRef);
124
- const eventDef = messages.find(m => m.name === exampleEvent.eventRef);
125
- const linkingField = findPrimitiveLinkingField(stateSchema?.fields || [], eventDef?.fields || []);
126
- if (linkingField) {
127
- const linkingValue = gs.exampleData[linkingField];
127
+ if (pairStreamPattern) {
128
+ const resolvedStream = pairStreamPattern.replace(/\$\{([^}]+)\}/g, (_, key) =>
129
+ String(exampleEvent.exampleData?.[key] ?? gs.exampleData?.[key] ?? 'unknown')
130
+ );
131
+ -%>
132
+ await eventStore.appendToStream('<%= resolvedStream %>', [{
133
+ type: '<%= gs.eventRef %>Initialized',
134
+ data: <%- formatDataObject(gs.exampleData, stateSchema) %>
135
+ }]);
136
+ <% } else {
137
+ const eventDef = messages.find(m => m.name === exampleEvent.eventRef);
138
+ const linkingField = findPrimitiveLinkingField(stateSchema?.fields || [], eventDef?.fields || []);
139
+ if (linkingField) {
140
+ const linkingValue = gs.exampleData[linkingField];
128
141
  -%>
129
142
  await eventStore.appendToStream('<%= gs.eventRef %>-<%= linkingValue %>', [{
130
143
  type: '<%= gs.eventRef %>Initialized',
131
144
  data: <%- formatDataObject(gs.exampleData, stateSchema) %>
132
145
  }]);
133
- <% }
146
+ <% }
147
+ }
134
148
  }
135
149
  -%>
136
150
  <%
@@ -22,6 +22,7 @@ const uniqueEventTypeNames = [...new Set(allEventTypeNames)];
22
22
  const eventUnion = uniqueEventTypeNames.join(' | ');
23
23
 
24
24
  const willHaveAnyAggregateStream = eventCommandPairs.some(pair => {
25
+ if (pair.streamPattern) return true;
25
26
  const eventDef = messages.find(m => m.name === pair.eventType);
26
27
  return states.some(state =>
27
28
  findPrimitiveLinkingField(state.fields, eventDef?.fields || []) !== undefined
@@ -84,18 +85,43 @@ eachMessage: async (event): Promise<MessageHandlerResult> => {
84
85
  <% } -%>
85
86
  <% if (states.length > 0) {
86
87
  let hasAggregateStream = false;
87
- for (const state of states) {
88
- const linkingField = findPrimitiveLinkingField(state.fields, eventDef?.fields || []);
89
- if (linkingField) {
90
- hasAggregateStream = true;
91
- const varName = camelCase(state.type);
88
+ if (pair.streamPattern) {
89
+ hasAggregateStream = true;
90
+ const streamExpr = pair.streamPattern.replace(/\$\{([^}]+)\}/g, (_, key) => '${event.data.' + key + '}');
91
+ const varName = camelCase(states[0].type);
92
+ for (const state of states) {
92
93
  for (const f of state.fields) {
93
94
  if (!stateFieldSources[f.name]) stateFieldSources[f.name] = varName;
94
95
  }
95
- const stateUsedByCommand = commandFields.some(f =>
96
- !eventFieldSet.has(f.name) && state.fields.some(sf => sf.name === f.name)
97
- );
98
- const varPrefix = stateUsedByCommand ? '' : '_';
96
+ }
97
+ const stateUsedByCommand = commandFields.some(f =>
98
+ !eventFieldSet.has(f.name) && states.some(s => s.fields.some(sf => sf.name === f.name))
99
+ );
100
+ const varPrefix = stateUsedByCommand ? '' : '_';
101
+ -%>
102
+
103
+ const { state: <%= varPrefix %><%= varName %> } = await eventStore.aggregateStream(
104
+ `<%= streamExpr %>`,
105
+ {
106
+ evolve: (currentState: Record<string, unknown>, evt: { type: string; data: Record<string, unknown> }) => ({ ...currentState, ...evt.data }),
107
+ initialState: (): Record<string, unknown> => ({}),
108
+ },
109
+ );
110
+ // <%= states[0].type %> fields: <%= states.flatMap(s => s.fields).map(f => f.name).join(', ') %>
111
+
112
+ <% } else {
113
+ for (const state of states) {
114
+ const linkingField = findPrimitiveLinkingField(state.fields, eventDef?.fields || []);
115
+ if (linkingField) {
116
+ hasAggregateStream = true;
117
+ const varName = camelCase(state.type);
118
+ for (const f of state.fields) {
119
+ if (!stateFieldSources[f.name]) stateFieldSources[f.name] = varName;
120
+ }
121
+ const stateUsedByCommand = commandFields.some(f =>
122
+ !eventFieldSet.has(f.name) && state.fields.some(sf => sf.name === f.name)
123
+ );
124
+ const varPrefix = stateUsedByCommand ? '' : '_';
99
125
  -%>
100
126
 
101
127
  const { state: <%= varPrefix %><%= varName %> } = await eventStore.aggregateStream(
@@ -107,7 +133,8 @@ eachMessage: async (event): Promise<MessageHandlerResult> => {
107
133
  );
108
134
  // <%= state.type %> fields: <%= state.fields.map(f => f.name).join(', ') %>
109
135
 
110
- <% }
136
+ <% }
137
+ }
111
138
  }
112
139
  if (!hasAggregateStream) {
113
140
  -%>
@@ -155,17 +182,41 @@ eachMessage: async (event): Promise<MessageHandlerResult> => {
155
182
  // Command (<%= pair.commandType %>) fields: <%= commandDef.fields.map(f => f.name + ': ' + (f.tsType || f.type)).join(', ') %>
156
183
  <% } -%>
157
184
  <% if (states.length > 0) {
158
- for (const state of states) {
159
- const linkingField = findPrimitiveLinkingField(state.fields, eventDef?.fields || []);
160
- if (linkingField) {
161
- const varName = camelCase(state.type);
185
+ if (pair.streamPattern) {
186
+ const streamExpr = pair.streamPattern.replace(/\$\{([^}]+)\}/g, (_, key) => '${event.data.' + key + '}');
187
+ const varName = camelCase(states[0].type);
188
+ for (const state of states) {
162
189
  for (const f of state.fields) {
163
190
  if (!stateFieldSources[f.name]) stateFieldSources[f.name] = varName;
164
191
  }
165
- const stateUsedByCommand = commandFields.some(f =>
166
- !eventFieldSet.has(f.name) && state.fields.some(sf => sf.name === f.name)
167
- );
168
- const varPrefix = stateUsedByCommand ? '' : '_';
192
+ }
193
+ const stateUsedByCommand = commandFields.some(f =>
194
+ !eventFieldSet.has(f.name) && states.some(s => s.fields.some(sf => sf.name === f.name))
195
+ );
196
+ const varPrefix = stateUsedByCommand ? '' : '_';
197
+ -%>
198
+
199
+ const { state: <%= varPrefix %><%= varName %> } = await eventStore.aggregateStream(
200
+ `<%= streamExpr %>`,
201
+ {
202
+ evolve: (currentState: Record<string, unknown>, evt: { type: string; data: Record<string, unknown> }) => ({ ...currentState, ...evt.data }),
203
+ initialState: (): Record<string, unknown> => ({}),
204
+ },
205
+ );
206
+ // <%= states[0].type %> fields: <%= states.flatMap(s => s.fields).map(f => f.name).join(', ') %>
207
+
208
+ <% } else {
209
+ for (const state of states) {
210
+ const linkingField = findPrimitiveLinkingField(state.fields, eventDef?.fields || []);
211
+ if (linkingField) {
212
+ const varName = camelCase(state.type);
213
+ for (const f of state.fields) {
214
+ if (!stateFieldSources[f.name]) stateFieldSources[f.name] = varName;
215
+ }
216
+ const stateUsedByCommand = commandFields.some(f =>
217
+ !eventFieldSet.has(f.name) && state.fields.some(sf => sf.name === f.name)
218
+ );
219
+ const varPrefix = stateUsedByCommand ? '' : '_';
169
220
  -%>
170
221
 
171
222
  const { state: <%= varPrefix %><%= varName %> } = await eventStore.aggregateStream(
@@ -177,7 +228,8 @@ eachMessage: async (event): Promise<MessageHandlerResult> => {
177
228
  );
178
229
  // <%= state.type %> fields: <%= state.fields.map(f => f.name).join(', ') %>
179
230
 
180
- <% }
231
+ <% }
232
+ }
181
233
  }
182
234
  }
183
235
  -%>
@@ -1008,4 +1008,143 @@ export type BarberNotified = Event<
1008
1008
  "
1009
1009
  `);
1010
1010
  });
1011
+
1012
+ it('should use target command stream pattern for aggregateStream when destination exists', async () => {
1013
+ const spec: SpecsSchema = {
1014
+ variant: 'specs',
1015
+ scenes: [
1016
+ {
1017
+ name: 'booking scene',
1018
+ moments: [
1019
+ {
1020
+ type: 'command',
1021
+ name: 'accept booking',
1022
+ client: { specs: [] },
1023
+ server: {
1024
+ description: '',
1025
+ data: {
1026
+ items: [
1027
+ {
1028
+ target: { type: 'Event', name: 'BookingAccepted' },
1029
+ destination: {
1030
+ type: 'stream',
1031
+ pattern: 'bookings-${bookingId}',
1032
+ },
1033
+ },
1034
+ ],
1035
+ },
1036
+ specs: [
1037
+ {
1038
+ type: 'gherkin',
1039
+ feature: 'Accept booking',
1040
+ rules: [
1041
+ {
1042
+ name: 'Should accept',
1043
+ examples: [
1044
+ {
1045
+ name: 'Booking accepted',
1046
+ steps: [
1047
+ {
1048
+ keyword: 'When',
1049
+ text: 'AcceptBooking',
1050
+ docString: { bookingId: 'b1', hostId: 'h1' },
1051
+ },
1052
+ {
1053
+ keyword: 'Then',
1054
+ text: 'BookingAccepted',
1055
+ docString: { bookingId: 'b1', hostId: 'h1' },
1056
+ },
1057
+ ],
1058
+ },
1059
+ ],
1060
+ },
1061
+ ],
1062
+ },
1063
+ ],
1064
+ },
1065
+ },
1066
+ {
1067
+ type: 'react',
1068
+ name: 'notify guest of acceptance',
1069
+ server: {
1070
+ description: 'Notifies guest when booking accepted',
1071
+ specs: [
1072
+ {
1073
+ type: 'gherkin',
1074
+ feature: 'Notify guest reaction',
1075
+ rules: [
1076
+ {
1077
+ name: 'Should notify guest',
1078
+ examples: [
1079
+ {
1080
+ name: 'Guest notified',
1081
+ steps: [
1082
+ {
1083
+ keyword: 'Given',
1084
+ text: 'BookingState',
1085
+ docString: { bookingId: 'b1', guestEmail: 'guest@test.com' },
1086
+ },
1087
+ {
1088
+ keyword: 'When',
1089
+ text: 'BookingAccepted',
1090
+ docString: { bookingId: 'b1', hostId: 'h1' },
1091
+ },
1092
+ {
1093
+ keyword: 'Then',
1094
+ text: 'AcceptBooking',
1095
+ docString: { bookingId: 'b1', hostId: 'h1' },
1096
+ },
1097
+ ],
1098
+ },
1099
+ ],
1100
+ },
1101
+ ],
1102
+ },
1103
+ ],
1104
+ },
1105
+ },
1106
+ ],
1107
+ },
1108
+ ],
1109
+ messages: [
1110
+ {
1111
+ type: 'command',
1112
+ name: 'AcceptBooking',
1113
+ fields: [
1114
+ { name: 'bookingId', type: 'string', required: true },
1115
+ { name: 'hostId', type: 'string', required: true },
1116
+ ],
1117
+ },
1118
+ {
1119
+ type: 'event',
1120
+ name: 'BookingAccepted',
1121
+ source: 'internal',
1122
+ fields: [
1123
+ { name: 'bookingId', type: 'string', required: true },
1124
+ { name: 'hostId', type: 'string', required: true },
1125
+ ],
1126
+ },
1127
+ {
1128
+ type: 'state',
1129
+ name: 'BookingState',
1130
+ fields: [
1131
+ { name: 'bookingId', type: 'string', required: true },
1132
+ { name: 'guestEmail', type: 'string', required: true },
1133
+ ],
1134
+ },
1135
+ ],
1136
+ };
1137
+
1138
+ const { plans } = await generateScaffoldFilePlans(spec.scenes, spec.messages, undefined, 'src/domain/narratives');
1139
+ const reactFile = plans.find((p) => p.outputPath.endsWith('notify-guest-of-acceptance/react.ts'));
1140
+
1141
+ expect(reactFile?.contents).toContain('`bookings-${event.data.bookingId}`');
1142
+ expect(reactFile?.contents).toContain('aggregateStream');
1143
+ expect(reactFile?.contents).not.toContain('BookingState-${event.data');
1144
+
1145
+ const specFile = plans.find((p) => p.outputPath.endsWith('notify-guest-of-acceptance/react.specs.ts'));
1146
+
1147
+ expect(specFile?.contents).toContain("'bookings-b1'");
1148
+ expect(specFile?.contents).not.toContain('BookingState-');
1149
+ });
1011
1150
  });
@@ -59,17 +59,41 @@ async (event: <%= pascalCase(pair.eventType) %>) => {
59
59
  // Command (<%= pair.commandType %>) fields: <%= commandDef.fields.map(f => f.name + ': ' + (f.tsType || f.type)).join(', ') %>
60
60
  <% } -%>
61
61
  <% if (states.length > 0) {
62
- for (const state of states) {
63
- const linkingField = findPrimitiveLinkingField(state.fields, eventDef?.fields || []);
64
- if (linkingField) {
65
- const varName = camelCase(state.type);
62
+ if (pair.streamPattern) {
63
+ const streamExpr = pair.streamPattern.replace(/\$\{([^}]+)\}/g, (_, key) => '${event.data.' + key + '}');
64
+ const varName = camelCase(states[0].type);
65
+ for (const state of states) {
66
66
  for (const f of state.fields) {
67
67
  if (!stateFieldSources[f.name]) stateFieldSources[f.name] = varName;
68
68
  }
69
- const stateUsedByCommand = commandFields.some(f =>
70
- !eventFieldSet.has(f.name) && state.fields.some(sf => sf.name === f.name)
71
- );
72
- const varPrefix = stateUsedByCommand ? '' : '_';
69
+ }
70
+ const stateUsedByCommand = commandFields.some(f =>
71
+ !eventFieldSet.has(f.name) && states.some(s => s.fields.some(sf => sf.name === f.name))
72
+ );
73
+ const varPrefix = stateUsedByCommand ? '' : '_';
74
+ -%>
75
+
76
+ const { state: <%= varPrefix %><%= varName %> } = await eventStore.aggregateStream(
77
+ `<%= streamExpr %>`,
78
+ {
79
+ evolve: (currentState: Record<string, unknown>, evt: { type: string; data: Record<string, unknown> }) => ({ ...currentState, ...evt.data }),
80
+ initialState: (): Record<string, unknown> => ({}),
81
+ },
82
+ );
83
+ // <%= states[0].type %> fields: <%= states.flatMap(s => s.fields).map(f => f.name).join(', ') %>
84
+
85
+ <% } else {
86
+ for (const state of states) {
87
+ const linkingField = findPrimitiveLinkingField(state.fields, eventDef?.fields || []);
88
+ if (linkingField) {
89
+ const varName = camelCase(state.type);
90
+ for (const f of state.fields) {
91
+ if (!stateFieldSources[f.name]) stateFieldSources[f.name] = varName;
92
+ }
93
+ const stateUsedByCommand = commandFields.some(f =>
94
+ !eventFieldSet.has(f.name) && state.fields.some(sf => sf.name === f.name)
95
+ );
96
+ const varPrefix = stateUsedByCommand ? '' : '_';
73
97
  -%>
74
98
 
75
99
  const { state: <%= varPrefix %><%= varName %> } = await eventStore.aggregateStream(
@@ -81,7 +105,8 @@ async (event: <%= pascalCase(pair.eventType) %>) => {
81
105
  );
82
106
  // <%= state.type %> fields: <%= state.fields.map(f => f.name).join(', ') %>
83
107
 
84
- <% }
108
+ <% }
109
+ }
85
110
  }
86
111
  }
87
112
  -%>