@auto-engineer/server-generator-apollo-emmett 1.110.0 → 1.110.2

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/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.110.0",
36
- "@auto-engineer/message-bus": "1.110.0"
35
+ "@auto-engineer/narrative": "1.110.2",
36
+ "@auto-engineer/message-bus": "1.110.2"
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.110.0"
47
+ "@auto-engineer/cli": "1.110.2"
48
48
  },
49
- "version": "1.110.0",
49
+ "version": "1.110.2",
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",
@@ -374,6 +374,102 @@ describe('extractMessagesFromSpecs (command slice)', () => {
374
374
  });
375
375
  });
376
376
 
377
+ describe('extractMessagesFromSpecs (query slice)', () => {
378
+ it('should not include non-event When steps in the events list', () => {
379
+ const slice: Slice = {
380
+ type: 'query',
381
+ name: 'views workout details',
382
+ server: {
383
+ description: 'Shows workout details',
384
+ data: {
385
+ items: [
386
+ {
387
+ origin: {
388
+ type: 'projection',
389
+ idField: 'workoutId',
390
+ name: 'WorkoutDetailsProjection',
391
+ },
392
+ target: {
393
+ type: 'State',
394
+ name: 'WorkoutDetails',
395
+ },
396
+ },
397
+ ],
398
+ },
399
+ specs: [
400
+ {
401
+ type: 'gherkin',
402
+ feature: 'Workout details query',
403
+ rules: [
404
+ {
405
+ name: 'Should show workout details',
406
+ examples: [
407
+ {
408
+ name: 'Workout details shown',
409
+ steps: [
410
+ {
411
+ keyword: 'Given',
412
+ text: 'WorkoutLogged',
413
+ docString: { workoutId: 'wkt_001', exercise: 'squat' },
414
+ },
415
+ {
416
+ keyword: 'When',
417
+ text: 'GetWorkoutDetails',
418
+ docString: {},
419
+ },
420
+ {
421
+ keyword: 'Then',
422
+ text: 'WorkoutDetails',
423
+ docString: { workoutId: 'wkt_001', exercise: 'squat' },
424
+ },
425
+ ],
426
+ },
427
+ ],
428
+ },
429
+ ],
430
+ },
431
+ ],
432
+ },
433
+ };
434
+
435
+ const allMessages: MessageDefinition[] = [
436
+ {
437
+ type: 'event',
438
+ name: 'WorkoutLogged',
439
+ fields: [
440
+ { name: 'workoutId', type: 'string', required: true },
441
+ { name: 'exercise', type: 'string', required: true },
442
+ ],
443
+ },
444
+ {
445
+ type: 'state',
446
+ name: 'WorkoutDetails',
447
+ fields: [
448
+ { name: 'workoutId', type: 'string', required: true },
449
+ { name: 'exercise', type: 'string', required: true },
450
+ ],
451
+ },
452
+ ];
453
+
454
+ const result = extractMessagesFromSpecs(slice, allMessages);
455
+
456
+ expect(result.events).toEqual([
457
+ {
458
+ type: 'WorkoutLogged',
459
+ fields: [
460
+ { name: 'workoutId', tsType: 'string', required: true },
461
+ { name: 'exercise', tsType: 'string', required: true },
462
+ ],
463
+ source: 'given',
464
+ sourceFlowName: undefined,
465
+ sourceSliceName: undefined,
466
+ },
467
+ ]);
468
+
469
+ expect(result.events.some((e) => e.type === 'GetWorkoutDetails')).toBe(false);
470
+ });
471
+ });
472
+
377
473
  describe('extractMessagesFromSpecs (react slice with data target events)', () => {
378
474
  it('should extract data target events with source then', () => {
379
475
  const slice: Slice = {
@@ -132,19 +132,21 @@ function extractMessagesForQuery(slice: Slice, allMessages: MessageDefinition[])
132
132
  const events: Message[] = gwtSpecs.flatMap((gwt) => {
133
133
  const eventsFromWhen: EventRef[] = Array.isArray(gwt.when) ? gwt.when : [];
134
134
  const givenEvents = extractEventsFromGiven(gwt.given, allMessages);
135
- const whenEvents: Message[] = eventsFromWhen.map((eventExample) => {
136
- const fields = extractFieldsFromMessage(eventExample.eventRef, 'event', allMessages);
137
- const messageDef = allMessages.find((m) => m.type === 'event' && m.name === eventExample.eventRef);
138
- const metadata = messageDef?.metadata as { sourceFlowName?: string; sourceSliceName?: string } | undefined;
139
-
140
- return {
141
- type: eventExample.eventRef,
142
- fields,
143
- source: 'when',
144
- sourceFlowName: metadata?.sourceFlowName,
145
- sourceSliceName: metadata?.sourceSliceName,
146
- };
147
- });
135
+ const whenEvents: Message[] = eventsFromWhen
136
+ .filter((eventExample) => allMessages.some((m) => m.type === 'event' && m.name === eventExample.eventRef))
137
+ .map((eventExample) => {
138
+ const fields = extractFieldsFromMessage(eventExample.eventRef, 'event', allMessages);
139
+ const messageDef = allMessages.find((m) => m.type === 'event' && m.name === eventExample.eventRef);
140
+ const metadata = messageDef?.metadata as { sourceFlowName?: string; sourceSliceName?: string } | undefined;
141
+
142
+ return {
143
+ type: eventExample.eventRef,
144
+ fields,
145
+ source: 'when',
146
+ sourceFlowName: metadata?.sourceFlowName,
147
+ sourceSliceName: metadata?.sourceSliceName,
148
+ };
149
+ });
148
150
 
149
151
  return [...givenEvents, ...whenEvents];
150
152
  });
@@ -477,4 +477,106 @@ describe('generateScaffoldFilePlans', () => {
477
477
  "
478
478
  `);
479
479
  });
480
+ it('should generate randomUUID for stream vars not in any command', async () => {
481
+ const spec: SpecsSchema = {
482
+ variant: 'specs',
483
+ narratives: [
484
+ {
485
+ name: 'Fitness tracker',
486
+ slices: [
487
+ {
488
+ type: 'command',
489
+ name: 'Log workout',
490
+ stream: 'workouts-${workoutId}',
491
+ client: { specs: [] },
492
+ server: {
493
+ description: 'test',
494
+ specs: [
495
+ {
496
+ type: 'gherkin',
497
+ feature: 'Log workout',
498
+ rules: [
499
+ {
500
+ name: 'Should log workout',
501
+ examples: [
502
+ {
503
+ name: 'Workout logged',
504
+ steps: [
505
+ {
506
+ keyword: 'When',
507
+ text: 'LogWorkout',
508
+ docString: { exercise: 'squat', reps: 10 },
509
+ },
510
+ {
511
+ keyword: 'Then',
512
+ text: 'WorkoutLogged',
513
+ docString: { exercise: 'squat', reps: 10 },
514
+ },
515
+ ],
516
+ },
517
+ ],
518
+ },
519
+ ],
520
+ },
521
+ ],
522
+ data: {
523
+ items: [
524
+ {
525
+ target: { type: 'Event', name: 'WorkoutLogged' },
526
+ destination: { type: 'stream', pattern: 'workouts-${workoutId}' },
527
+ },
528
+ ],
529
+ },
530
+ },
531
+ },
532
+ ],
533
+ },
534
+ ],
535
+ messages: [
536
+ {
537
+ type: 'command',
538
+ name: 'LogWorkout',
539
+ fields: [
540
+ { name: 'exercise', type: 'string', required: true },
541
+ { name: 'reps', type: 'number', required: true },
542
+ ],
543
+ },
544
+ {
545
+ type: 'event',
546
+ name: 'WorkoutLogged',
547
+ source: 'internal',
548
+ fields: [
549
+ { name: 'exercise', type: 'string', required: true },
550
+ { name: 'reps', type: 'number', required: true },
551
+ ],
552
+ },
553
+ ],
554
+ };
555
+
556
+ const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
557
+ const handleFile = plans.find((p) => p.outputPath.endsWith('handle.ts'));
558
+
559
+ expect(handleFile?.contents).toMatchInlineSnapshot(`
560
+ "import { randomUUID } from 'crypto';
561
+
562
+ import { CommandHandler, type EventStore, type MessageHandlerResult } from '@event-driven-io/emmett';
563
+ import { evolve } from './evolve';
564
+ import { initialState } from './state';
565
+ import { decide } from './decide';
566
+ import type { LogWorkout } from './commands';
567
+
568
+ const handler = CommandHandler({
569
+ evolve,
570
+ initialState,
571
+ });
572
+
573
+ export const handle = async (eventStore: EventStore, command: LogWorkout): Promise<MessageHandlerResult> => {
574
+ const streamId = \`workouts-\${randomUUID()}\`;
575
+
576
+ await handler(eventStore, streamId, (state) => decide(command, state));
577
+ return undefined;
578
+ };
579
+ "
580
+ `);
581
+ });
480
582
  });
@@ -63,8 +63,14 @@ while ((svMatch = svRegex.exec(streamPatternStr)) !== null) {
63
63
  }
64
64
  const allCmdFieldSets = commands.map(c => new Set((c.fields ?? []).map(f => f.name)));
65
65
  const needsStreamGuard = streamVars.length > 0 && !allCmdFieldSets.every(fs => streamVars.every(v => fs.has(v)));
66
+ const varInAnyCommand = (v) => allCmdFieldSets.some(fs => fs.has(v));
67
+ const guardVars = needsStreamGuard ? streamVars.filter(v => varInAnyCommand(v)) : [];
68
+ const uuidVars = needsStreamGuard ? streamVars.filter(v => !varInAnyCommand(v)) : [];
66
69
  %>
67
70
 
71
+ <% if (uuidVars.length > 0) { -%>
72
+ import { randomUUID } from 'crypto';
73
+ <% } -%>
68
74
  <% integrationSideEffectImports.forEach((importSource) => { %>
69
75
  import '<%= importSource %>';
70
76
  <% }); %>
@@ -96,16 +102,21 @@ eventStore: EventStore,
96
102
  command: <%= commands.map(c => pascalCase(c.type)).join(' | ') %>
97
103
  ): Promise<MessageHandlerResult> => {
98
104
  <% if (stream?.pattern?.includes('${')) { -%>
99
- <% if (needsStreamGuard) { -%>
100
- const commandData = command.data;
101
- <% for (const v of streamVars) { -%>
102
- if (!('<%= v %>' in commandData)) {
103
- throw new Error('Cannot determine stream: "<%= v %>" not in command data');
104
- }
105
- <% } -%>
106
- const streamId = `<%= stream.pattern.replace(/\$\{([^}]+)\}/g, (_, key) => `\${commandData.${key}}`) %>`;
107
- <% } else { -%>
105
+ <% if (!needsStreamGuard) { -%>
108
106
  const streamId = `<%= stream.pattern.replace(/\$\{([^}]+)\}/g, (_, key) => `\${command.data.${key}}`) %>`;
107
+ <% } else { -%>
108
+ <% if (guardVars.length > 0) { -%>
109
+ const commandData = command.data;
110
+ <% for (const v of guardVars) { -%>
111
+ if (!('<%= v %>' in commandData)) {
112
+ throw new Error('Cannot determine stream: "<%= v %>" not in command data');
113
+ }
114
+ <% } -%>
115
+ <% } -%>
116
+ const streamId = `<%= stream.pattern.replace(/\$\{([^}]+)\}/g, (_, key) => {
117
+ if (varInAnyCommand(key)) return '${commandData.' + key + '}';
118
+ return '${randomUUID()}';
119
+ }) %>`;
109
120
  <% } -%>
110
121
  <% } else { -%>
111
122
  const streamId = '<%= stream?.pattern ?? 'unknown-stream' %>';
@@ -114,9 +114,9 @@ describe('query.resolver.ts.ejs', () => {
114
114
  return model.find((item) => {
115
115
  if (location !== undefined && item.location !== location) return false;
116
116
 
117
- if (maxPrice !== undefined && item.maxPrice !== maxPrice) return false;
117
+ // TODO: 'maxPrice' has no matching field on the state type — implement custom filter logic.
118
118
 
119
- if (minGuests !== undefined && item.minGuests !== minGuests) return false;
119
+ // TODO: 'minGuests' has no matching field on the state type — implement custom filter logic.
120
120
 
121
121
  return true;
122
122
  });
@@ -935,7 +935,7 @@ describe('query.resolver.ts.ejs', () => {
935
935
  // If this query should return a single item, switch to findOne().
936
936
 
937
937
  return model.find((item) => {
938
- if (pantryId !== undefined && item.pantryId !== pantryId) return false;
938
+ // TODO: 'pantryId' has no matching field on the state type — implement custom filter logic.
939
939
 
940
940
  return true;
941
941
  });
@@ -1177,7 +1177,7 @@ describe('query.resolver.ts.ejs', () => {
1177
1177
  // If this query should return a single item, switch to findOne().
1178
1178
 
1179
1179
  return model.find((item) => {
1180
- if (pantryId !== undefined && item.pantryId !== pantryId) return false;
1180
+ // TODO: 'pantryId' has no matching field on the state type — implement custom filter logic.
1181
1181
 
1182
1182
  return true;
1183
1183
  });
@@ -165,9 +165,15 @@ const model = new ReadModel<<%= viewType %>>(ctx.database, '<%= collectionName %
165
165
 
166
166
  return model.find((<%= hasArgs ? 'item' : '_item' %>) => {
167
167
  <% if (parsedRequest?.args?.length) {
168
- for (const arg of parsedRequest.args) { %>
168
+ const stateFieldNames = new Set(messageFields.map(f => f.name));
169
+ for (const arg of parsedRequest.args) {
170
+ if (stateFieldNames.has(arg.name)) { %>
169
171
  if (<%= arg.name %> !== undefined && item.<%= arg.name %> !== <%= arg.name %>) return false;
170
- <% } } %>
172
+ <% } else { %>
173
+ // TODO: '<%= arg.name %>' has no matching field on the state type — implement custom filter logic.
174
+ <% }
175
+ }
176
+ } %>
171
177
  return true;
172
178
  });
173
179
  }
@@ -132,11 +132,32 @@ describe('<%= ruleDescription %>', () => {
132
132
  }]);
133
133
  <% }
134
134
  }
135
+ -%>
136
+ <%
137
+ const eventSchema = events.find(e => e.type === exampleEvent.eventRef);
138
+ const mergedEventData = { ...(exampleEvent.exampleData || {}) };
139
+ const eventFieldNames = new Set((eventSchema?.fields || []).map(f => f.name));
140
+ for (const cmd of thenCommands) {
141
+ for (const [key, value] of Object.entries(cmd.exampleData || {})) {
142
+ if (!(key in mergedEventData) && eventFieldNames.has(key)) {
143
+ mergedEventData[key] = value;
144
+ }
145
+ }
146
+ }
147
+ for (const field of (eventSchema?.fields || [])) {
148
+ if (!(field.name in mergedEventData)) {
149
+ const tsType = field.tsType || field.type || 'string';
150
+ if (tsType === 'number') mergedEventData[field.name] = 0;
151
+ else if (tsType === 'boolean') mergedEventData[field.name] = false;
152
+ else if (!tsType.includes('{') && !tsType.includes('[]') && !tsType.includes('Array'))
153
+ mergedEventData[field.name] = `test-${field.name}`;
154
+ }
155
+ }
135
156
  -%>
136
157
  await given([])
137
158
  .when({
138
159
  type: '<%= exampleEvent.eventRef %>',
139
- data: <%- formatDataObject(exampleEvent.exampleData, events.find(e => e.type === exampleEvent.eventRef)) %>
160
+ data: <%- formatDataObject(mergedEventData, eventSchema) %>
140
161
  })
141
162
  <% if (thenCommands.length === 1) {
142
163
  const commandSchema = thenCommands[0];