@auto-engineer/server-generator-apollo-emmett 1.86.0 → 1.88.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 (33) 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 +40 -0
  5. package/dist/src/codegen/extract/messages.d.ts +1 -1
  6. package/dist/src/codegen/extract/messages.d.ts.map +1 -1
  7. package/dist/src/codegen/extract/projection.d.ts +1 -1
  8. package/dist/src/codegen/extract/projection.d.ts.map +1 -1
  9. package/dist/src/codegen/extract/projection.js +14 -1
  10. package/dist/src/codegen/extract/projection.js.map +1 -1
  11. package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
  12. package/dist/src/codegen/templates/command/decide.specs.specs.ts +306 -0
  13. package/dist/src/codegen/templates/command/decide.specs.ts +4 -4
  14. package/dist/src/codegen/templates/command/decide.specs.ts.ejs +27 -1
  15. package/dist/src/codegen/templates/command/decide.ts.ejs +1 -1
  16. package/dist/src/codegen/templates/query/projection.specs.specs.ts +174 -1
  17. package/dist/src/codegen/templates/query/projection.specs.ts.ejs +38 -12
  18. package/dist/src/commands/generate-server.d.ts +7 -1
  19. package/dist/src/commands/generate-server.d.ts.map +1 -1
  20. package/dist/src/index.d.ts +7 -1
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/tsconfig.tsbuildinfo +1 -1
  23. package/ketchup-plan.md +5 -1
  24. package/package.json +4 -4
  25. package/src/codegen/extract/messages.ts +1 -1
  26. package/src/codegen/extract/projection.ts +13 -3
  27. package/src/codegen/scaffoldFromSchema.ts +1 -1
  28. package/src/codegen/templates/command/decide.specs.specs.ts +306 -0
  29. package/src/codegen/templates/command/decide.specs.ts +4 -4
  30. package/src/codegen/templates/command/decide.specs.ts.ejs +27 -1
  31. package/src/codegen/templates/command/decide.ts.ejs +1 -1
  32. package/src/codegen/templates/query/projection.specs.specs.ts +174 -1
  33. package/src/codegen/templates/query/projection.specs.ts.ejs +38 -12
package/ketchup-plan.md CHANGED
@@ -1,7 +1,11 @@
1
- # Ketchup Plan: Slim ReadModel API add findOne, remove redundant methods
1
+ # Ketchup Plan: Fix generator template bugs found in typical server
2
2
 
3
3
  ## TODO
4
4
 
5
+ - [x] Burst 8: Return array `idField` natively from extraction (d4633c71)
6
+ - [x] Burst 9: Fix `findOne` fallback with safe value resolution (8d3ee9cd)
7
+ - [x] Burst 10: Derive stable `metadata.now` with narrow trigger
8
+
5
9
  ## DONE
6
10
 
7
11
  - [x] Burst 1: Slim ReadModel to find + findOne, update EJS template API docs, update all inline snapshots
package/package.json CHANGED
@@ -32,8 +32,8 @@
32
32
  "uuid": "^11.0.0",
33
33
  "web-streams-polyfill": "^4.1.0",
34
34
  "zod": "^3.22.4",
35
- "@auto-engineer/narrative": "1.86.0",
36
- "@auto-engineer/message-bus": "1.86.0"
35
+ "@auto-engineer/narrative": "1.88.0",
36
+ "@auto-engineer/message-bus": "1.88.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.86.0"
47
+ "@auto-engineer/cli": "1.88.0"
48
48
  },
49
- "version": "1.86.0",
49
+ "version": "1.88.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",
@@ -20,7 +20,7 @@ export interface ExtractedMessages {
20
20
  events: Message[];
21
21
  states: Message[];
22
22
  commandSchemasByName: Record<string, Message>;
23
- projectionIdField?: string;
23
+ projectionIdField?: string | string[];
24
24
  projectionSingleton?: boolean;
25
25
  }
26
26
 
@@ -2,7 +2,7 @@ import type { Slice } from '@auto-engineer/narrative';
2
2
 
3
3
  interface ProjectionOrigin {
4
4
  type: 'projection';
5
- idField?: string;
5
+ idField?: string | string[];
6
6
  name?: string;
7
7
  singleton?: boolean;
8
8
  }
@@ -40,8 +40,18 @@ function extractProjectionField<K extends keyof ProjectionOrigin>(slice: Slice,
40
40
  return undefined;
41
41
  }
42
42
 
43
- export function extractProjectionIdField(slice: Slice): string | undefined {
44
- return extractProjectionField(slice, 'idField');
43
+ export function extractProjectionIdField(slice: Slice): string | string[] | undefined {
44
+ if (!('server' in slice)) return undefined;
45
+ const dataSource = slice.server?.data?.items?.[0];
46
+ if (!hasOrigin(dataSource)) return undefined;
47
+
48
+ const origin = dataSource.origin;
49
+ if (isProjectionOrigin(origin)) {
50
+ const { idField } = origin;
51
+ if (typeof idField === 'string') return idField;
52
+ if (Array.isArray(idField) && idField.length > 0) return idField;
53
+ }
54
+ return undefined;
45
55
  }
46
56
 
47
57
  export function extractProjectionName(slice: Slice): string | undefined {
@@ -547,7 +547,7 @@ async function prepareTemplateData(
547
547
  events: Message[],
548
548
  states: Message[],
549
549
  commandSchemasByName: Record<string, Message>,
550
- projectionIdField: string | undefined,
550
+ projectionIdField: string | string[] | undefined,
551
551
  projectionSingleton: boolean | undefined,
552
552
  allMessages?: MessageDefinition[],
553
553
  integrations?: Model['integrations'],
@@ -822,4 +822,310 @@ describe('spec.ts.ejs', () => {
822
822
  expect(decideFile?.contents).toContain('Business rules:');
823
823
  expect(decideFile?.contents).toContain('Host can only approve pending bookings');
824
824
  });
825
+
826
+ it('should pin metadata.now when Then event has a derived ISO date not in command fields or Given', async () => {
827
+ const spec: SpecsSchema = {
828
+ variant: 'specs',
829
+ narratives: [
830
+ {
831
+ name: 'Record flow',
832
+ slices: [
833
+ {
834
+ type: 'command',
835
+ name: 'Update record',
836
+ client: { specs: [] },
837
+ server: {
838
+ description: '',
839
+ specs: [
840
+ {
841
+ type: 'gherkin',
842
+ feature: 'Update record',
843
+ rules: [
844
+ {
845
+ name: 'Should update personal record',
846
+ examples: [
847
+ {
848
+ name: 'Record updated with derived date',
849
+ steps: [
850
+ {
851
+ keyword: 'When',
852
+ text: 'UpdateRecord',
853
+ docString: { recordId: 'r1', value: 100 },
854
+ },
855
+ {
856
+ keyword: 'Then',
857
+ text: 'RecordUpdated',
858
+ docString: { recordId: 'r1', value: 100, date: '2024-01-20' },
859
+ },
860
+ ],
861
+ },
862
+ ],
863
+ },
864
+ ],
865
+ },
866
+ ],
867
+ },
868
+ },
869
+ ],
870
+ },
871
+ ],
872
+ messages: [
873
+ {
874
+ type: 'command',
875
+ name: 'UpdateRecord',
876
+ fields: [
877
+ { name: 'recordId', type: 'string', required: true },
878
+ { name: 'value', type: 'number', required: true },
879
+ ],
880
+ },
881
+ {
882
+ type: 'event',
883
+ name: 'RecordUpdated',
884
+ source: 'internal',
885
+ fields: [
886
+ { name: 'recordId', type: 'string', required: true },
887
+ { name: 'value', type: 'number', required: true },
888
+ { name: 'date', type: 'string', required: true },
889
+ ],
890
+ },
891
+ ],
892
+ };
893
+
894
+ const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
895
+ const specFile = plans.find((p) => p.outputPath.endsWith('specs.ts'));
896
+
897
+ expect(specFile?.contents).toContain("metadata: { now: new Date('2024-01-20') }");
898
+ });
899
+
900
+ it('should keep new Date() when all date fields are in command schema', async () => {
901
+ const spec: SpecsSchema = {
902
+ variant: 'specs',
903
+ narratives: [
904
+ {
905
+ name: 'Schedule flow',
906
+ slices: [
907
+ {
908
+ type: 'command',
909
+ name: 'Schedule event',
910
+ client: { specs: [] },
911
+ server: {
912
+ description: '',
913
+ specs: [
914
+ {
915
+ type: 'gherkin',
916
+ feature: 'Schedule event',
917
+ rules: [
918
+ {
919
+ name: 'Should schedule',
920
+ examples: [
921
+ {
922
+ name: 'Event scheduled with user-provided date',
923
+ steps: [
924
+ {
925
+ keyword: 'When',
926
+ text: 'ScheduleEvent',
927
+ docString: { eventId: 'e1', scheduledDate: '2024-03-15' },
928
+ },
929
+ {
930
+ keyword: 'Then',
931
+ text: 'EventScheduled',
932
+ docString: { eventId: 'e1', scheduledDate: '2024-03-15' },
933
+ },
934
+ ],
935
+ },
936
+ ],
937
+ },
938
+ ],
939
+ },
940
+ ],
941
+ },
942
+ },
943
+ ],
944
+ },
945
+ ],
946
+ messages: [
947
+ {
948
+ type: 'command',
949
+ name: 'ScheduleEvent',
950
+ fields: [
951
+ { name: 'eventId', type: 'string', required: true },
952
+ { name: 'scheduledDate', type: 'string', required: true },
953
+ ],
954
+ },
955
+ {
956
+ type: 'event',
957
+ name: 'EventScheduled',
958
+ source: 'internal',
959
+ fields: [
960
+ { name: 'eventId', type: 'string', required: true },
961
+ { name: 'scheduledDate', type: 'string', required: true },
962
+ ],
963
+ },
964
+ ],
965
+ };
966
+
967
+ const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
968
+ const specFile = plans.find((p) => p.outputPath.endsWith('specs.ts'));
969
+
970
+ expect(specFile?.contents).toContain('metadata: { now: new Date() }');
971
+ expect(specFile?.contents).not.toContain("new Date('2024-03-15')");
972
+ });
973
+
974
+ it('should keep new Date() when date value also appears in Given events', async () => {
975
+ const spec: SpecsSchema = {
976
+ variant: 'specs',
977
+ narratives: [
978
+ {
979
+ name: 'Renewal flow',
980
+ slices: [
981
+ {
982
+ type: 'command',
983
+ name: 'Renew subscription',
984
+ client: { specs: [] },
985
+ server: {
986
+ description: '',
987
+ specs: [
988
+ {
989
+ type: 'gherkin',
990
+ feature: 'Renew subscription',
991
+ rules: [
992
+ {
993
+ name: 'Should renew',
994
+ examples: [
995
+ {
996
+ name: 'Renewal carries forward existing date',
997
+ steps: [
998
+ {
999
+ keyword: 'Given',
1000
+ text: 'SubscriptionCreated',
1001
+ docString: { subId: 's1', startDate: '2024-01-20' },
1002
+ },
1003
+ {
1004
+ keyword: 'When',
1005
+ text: 'RenewSubscription',
1006
+ docString: { subId: 's1' },
1007
+ },
1008
+ {
1009
+ keyword: 'Then',
1010
+ text: 'SubscriptionRenewed',
1011
+ docString: { subId: 's1', startDate: '2024-01-20' },
1012
+ },
1013
+ ],
1014
+ },
1015
+ ],
1016
+ },
1017
+ ],
1018
+ },
1019
+ ],
1020
+ },
1021
+ },
1022
+ ],
1023
+ },
1024
+ ],
1025
+ messages: [
1026
+ {
1027
+ type: 'command',
1028
+ name: 'RenewSubscription',
1029
+ fields: [{ name: 'subId', type: 'string', required: true }],
1030
+ },
1031
+ {
1032
+ type: 'event',
1033
+ name: 'SubscriptionCreated',
1034
+ source: 'internal',
1035
+ fields: [
1036
+ { name: 'subId', type: 'string', required: true },
1037
+ { name: 'startDate', type: 'string', required: true },
1038
+ ],
1039
+ },
1040
+ {
1041
+ type: 'event',
1042
+ name: 'SubscriptionRenewed',
1043
+ source: 'internal',
1044
+ fields: [
1045
+ { name: 'subId', type: 'string', required: true },
1046
+ { name: 'startDate', type: 'string', required: true },
1047
+ ],
1048
+ },
1049
+ ],
1050
+ };
1051
+
1052
+ const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
1053
+ const specFile = plans.find((p) => p.outputPath.endsWith('specs.ts'));
1054
+
1055
+ expect(specFile?.contents).toContain('metadata: { now: new Date() }');
1056
+ expect(specFile?.contents).not.toContain("new Date('2024-01-20')");
1057
+ });
1058
+
1059
+ it('should keep new Date() when multiple candidate dates create ambiguity', async () => {
1060
+ const spec: SpecsSchema = {
1061
+ variant: 'specs',
1062
+ narratives: [
1063
+ {
1064
+ name: 'Contract flow',
1065
+ slices: [
1066
+ {
1067
+ type: 'command',
1068
+ name: 'Sign contract',
1069
+ client: { specs: [] },
1070
+ server: {
1071
+ description: '',
1072
+ specs: [
1073
+ {
1074
+ type: 'gherkin',
1075
+ feature: 'Sign contract',
1076
+ rules: [
1077
+ {
1078
+ name: 'Should sign',
1079
+ examples: [
1080
+ {
1081
+ name: 'Contract signed with two derived dates',
1082
+ steps: [
1083
+ {
1084
+ keyword: 'When',
1085
+ text: 'SignContract',
1086
+ docString: { contractId: 'c1' },
1087
+ },
1088
+ {
1089
+ keyword: 'Then',
1090
+ text: 'ContractSigned',
1091
+ docString: { contractId: 'c1', signedDate: '2024-01-20', expiresOn: '2024-06-30' },
1092
+ },
1093
+ ],
1094
+ },
1095
+ ],
1096
+ },
1097
+ ],
1098
+ },
1099
+ ],
1100
+ },
1101
+ },
1102
+ ],
1103
+ },
1104
+ ],
1105
+ messages: [
1106
+ {
1107
+ type: 'command',
1108
+ name: 'SignContract',
1109
+ fields: [{ name: 'contractId', type: 'string', required: true }],
1110
+ },
1111
+ {
1112
+ type: 'event',
1113
+ name: 'ContractSigned',
1114
+ source: 'internal',
1115
+ fields: [
1116
+ { name: 'contractId', type: 'string', required: true },
1117
+ { name: 'signedDate', type: 'string', required: true },
1118
+ { name: 'expiresOn', type: 'string', required: true },
1119
+ ],
1120
+ },
1121
+ ],
1122
+ };
1123
+
1124
+ const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
1125
+ const specFile = plans.find((p) => p.outputPath.endsWith('specs.ts'));
1126
+
1127
+ expect(specFile?.contents).toContain('metadata: { now: new Date() }');
1128
+ expect(specFile?.contents).not.toContain("new Date('2024-01-20')");
1129
+ expect(specFile?.contents).not.toContain("new Date('2024-06-30')");
1130
+ });
825
1131
  });
@@ -96,7 +96,7 @@ describe('decide.ts.ejs', () => {
96
96
  *
97
97
  * You should:
98
98
  * - Validate the command input fields
99
- * - Inspect the current domain \`state\` to determine if the command is allowed
99
+ * - Inspect the current domain \`_state\` to determine if the command is allowed
100
100
  * - If invalid, throw one of the following domain errors: \`NotFoundError\`, \`ValidationError\`, or \`IllegalStateError\`
101
101
  * ⚠️ Error constructors: NotFoundError takes { id, type, message? }, while IllegalStateError/ValidationError take string
102
102
  * - If valid, return one or more events with the correct structure
@@ -229,7 +229,7 @@ describe('decide.ts.ejs', () => {
229
229
  *
230
230
  * You should:
231
231
  * - Validate the command input fields
232
- * - Inspect the current domain \`state\` to determine if the command is allowed
232
+ * - Inspect the current domain \`_state\` to determine if the command is allowed
233
233
  * - If invalid, throw one of the following domain errors: \`NotFoundError\`, \`ValidationError\`, or \`IllegalStateError\`
234
234
  * ⚠️ Error constructors: NotFoundError takes { id, type, message? }, while IllegalStateError/ValidationError take string
235
235
  * - If valid, return one or more events with the correct structure
@@ -382,7 +382,7 @@ describe('decide.ts.ejs', () => {
382
382
  *
383
383
  * You should:
384
384
  * - Validate the command input fields
385
- * - Inspect the current domain \`state\` to determine if the command is allowed
385
+ * - Inspect the current domain \`_state\` to determine if the command is allowed
386
386
  * - If invalid, throw one of the following domain errors: \`NotFoundError\`, \`ValidationError\`, or \`IllegalStateError\`
387
387
  * ⚠️ Error constructors: NotFoundError takes { id, type, message? }, while IllegalStateError/ValidationError take string
388
388
  * - If valid, return one or more events with the correct structure
@@ -579,7 +579,7 @@ describe('decide.ts.ejs', () => {
579
579
  *
580
580
  * You should:
581
581
  * - Validate the command input fields
582
- * - Inspect the current domain \`state\` to determine if the command is allowed
582
+ * - Inspect the current domain \`_state\` to determine if the command is allowed
583
583
  * - Use \`products\` (integration result) to enrich or filter the output
584
584
  * - If invalid, throw one of the following domain errors: \`NotFoundError\`, \`ValidationError\`, or \`IllegalStateError\`
585
585
  * ⚠️ Error constructors: NotFoundError takes { id, type, message? }, while IllegalStateError/ValidationError take string
@@ -53,6 +53,31 @@ for (const [importPath, eventTypes] of testEventsByPath.entries()) {
53
53
  }
54
54
 
55
55
  const uniqueEventTypes = Array.from(new Set(allEvents.map(e => e?.type).filter(Boolean))).sort();
56
+
57
+ function findDerivedDateValue(eventResults, commandSchema, givenEvents) {
58
+ const commandFields = new Set(commandSchema?.fields?.map(f => f.name) || []);
59
+ const givenValues = new Set();
60
+ for (const g of givenEvents || []) {
61
+ for (const val of Object.values(g.exampleData || {})) {
62
+ if (typeof val === 'string') givenValues.add(val);
63
+ }
64
+ }
65
+ const candidates = new Set();
66
+ for (const e of eventResults) {
67
+ const data = e.exampleData || {};
68
+ for (const [key, val] of Object.entries(data)) {
69
+ if (
70
+ !commandFields.has(key) &&
71
+ typeof val === 'string' &&
72
+ /^\d{4}-\d{2}-\d{2}$/.test(val) &&
73
+ !givenValues.has(val)
74
+ ) {
75
+ candidates.add(val);
76
+ }
77
+ }
78
+ }
79
+ return candidates.size === 1 ? [...candidates][0] : null;
80
+ }
56
81
  _%>
57
82
  import { describe, it } from 'vitest';
58
83
  import { DeciderSpecification } from '@event-driven-io/emmett';
@@ -97,7 +122,8 @@ describe('<%= ruleDescription %>', () => {
97
122
  .when({
98
123
  type: '<%= example.commandRef %>',
99
124
  data: <%- formatDataObject(example.exampleData, schema) %>,
100
- metadata: { now: new Date() },
125
+ <% const derivedDate = findDerivedDateValue(eventResults, schema, gwt.given); -%>
126
+ metadata: { now: <%= derivedDate ? `new Date('${derivedDate}')` : 'new Date()' %> },
101
127
  })
102
128
  <% if (errorResult) { %>
103
129
  .thenThrows((err) => err instanceof <%= errorResult.errorType %> && err.message === '<%= errorResult.message || '' %>');
@@ -60,7 +60,7 @@ case '<%= command %>': {
60
60
  *
61
61
  * You should:
62
62
  * - Validate the command input fields
63
- * - Inspect the current domain `state` to determine if the command is allowed
63
+ * - Inspect the current domain `_state` to determine if the command is allowed
64
64
  <% if (integrationReturnType) { -%>
65
65
  * - Use `<%= camelCase(integrationReturnType) %>` (integration result) to enrich or filter the output
66
66
  <% } -%>
@@ -1022,7 +1022,7 @@ describe('projection.specs.ts.ejs', () => {
1022
1022
  .then(async (state) => {
1023
1023
  const document = await state.database
1024
1024
  .collection<UserProject>('UserProjectsProjection')
1025
- .findOne((doc) => doc.id === 'test-id');
1025
+ .findOne((doc) => doc.userId === 'user_123' && doc.projectId === 'proj_456');
1026
1026
 
1027
1027
  const expected: UserProject = {
1028
1028
  userId: 'user_123',
@@ -2093,4 +2093,177 @@ describe('projection.specs.ts.ejs', () => {
2093
2093
  expect(specFile?.contents).toContain("service: 'haircut'");
2094
2094
  expect(specFile?.contents).not.toContain('"[{\\"appointmentId\\"');
2095
2095
  });
2096
+
2097
+ it('should resolve idField value from Given/When events when not in Then state data', async () => {
2098
+ const spec: SpecsSchema = {
2099
+ variant: 'specs',
2100
+ narratives: [
2101
+ {
2102
+ name: 'fitness-flow',
2103
+ slices: [
2104
+ {
2105
+ type: 'command',
2106
+ name: 'log-workout',
2107
+ stream: 'workouts-${memberId}',
2108
+ client: { specs: [] },
2109
+ server: {
2110
+ description: '',
2111
+ specs: [
2112
+ {
2113
+ type: 'gherkin',
2114
+ feature: 'Log workout',
2115
+ rules: [
2116
+ {
2117
+ name: 'Should log',
2118
+ examples: [
2119
+ {
2120
+ name: 'Logs workout',
2121
+ steps: [
2122
+ {
2123
+ keyword: 'When',
2124
+ text: 'LogWorkout',
2125
+ docString: { memberId: 'mem_001', caloriesBurned: 250 },
2126
+ },
2127
+ {
2128
+ keyword: 'Then',
2129
+ text: 'WorkoutRecorded',
2130
+ docString: { memberId: 'mem_001', caloriesBurned: 250 },
2131
+ },
2132
+ ],
2133
+ },
2134
+ ],
2135
+ },
2136
+ ],
2137
+ },
2138
+ ],
2139
+ },
2140
+ },
2141
+ {
2142
+ type: 'query',
2143
+ name: 'view-workout-summary',
2144
+ stream: 'workouts',
2145
+ client: { specs: [] },
2146
+ server: {
2147
+ description: '',
2148
+ data: {
2149
+ items: [
2150
+ {
2151
+ target: { type: 'State', name: 'WorkoutSummary' },
2152
+ origin: { type: 'projection', name: 'WorkoutSummaryProjection', idField: 'memberId' },
2153
+ },
2154
+ ],
2155
+ },
2156
+ specs: [
2157
+ {
2158
+ type: 'gherkin',
2159
+ feature: 'View workout summary',
2160
+ rules: [
2161
+ {
2162
+ name: 'Should summarize workouts',
2163
+ examples: [
2164
+ {
2165
+ name: 'Summary after workout recorded',
2166
+ steps: [
2167
+ {
2168
+ keyword: 'When',
2169
+ text: 'WorkoutRecorded',
2170
+ docString: { memberId: 'mem_001', caloriesBurned: 250 },
2171
+ },
2172
+ {
2173
+ keyword: 'Then',
2174
+ text: 'WorkoutSummary',
2175
+ docString: { totalCalories: 250, totalSessions: 1 },
2176
+ },
2177
+ ],
2178
+ },
2179
+ ],
2180
+ },
2181
+ ],
2182
+ },
2183
+ ],
2184
+ },
2185
+ },
2186
+ ],
2187
+ },
2188
+ ],
2189
+ messages: [
2190
+ {
2191
+ type: 'command',
2192
+ name: 'LogWorkout',
2193
+ fields: [
2194
+ { name: 'memberId', type: 'string', required: true },
2195
+ { name: 'caloriesBurned', type: 'number', required: true },
2196
+ ],
2197
+ },
2198
+ {
2199
+ type: 'event',
2200
+ name: 'WorkoutRecorded',
2201
+ source: 'internal',
2202
+ fields: [
2203
+ { name: 'memberId', type: 'string', required: true },
2204
+ { name: 'caloriesBurned', type: 'number', required: true },
2205
+ ],
2206
+ },
2207
+ {
2208
+ type: 'state',
2209
+ name: 'WorkoutSummary',
2210
+ fields: [
2211
+ { name: 'totalCalories', type: 'number', required: true },
2212
+ { name: 'totalSessions', type: 'number', required: true },
2213
+ ],
2214
+ },
2215
+ ],
2216
+ } as SpecsSchema;
2217
+
2218
+ const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
2219
+ const specFile = plans.find((p) => p.outputPath.endsWith('view-workout-summary/projection.specs.ts'));
2220
+
2221
+ expect(specFile?.contents).toMatchInlineSnapshot(`
2222
+ "import { describe, it, beforeEach, expect } from 'vitest';
2223
+ import { InMemoryProjectionSpec } from '@event-driven-io/emmett';
2224
+ import { projection } from './projection';
2225
+ import type { WorkoutRecorded } from '../log-workout/events';
2226
+ import { WorkoutSummary } from './state';
2227
+
2228
+ type ProjectionEvent = WorkoutRecorded;
2229
+
2230
+ describe('Should summarize workouts', () => {
2231
+ let given: InMemoryProjectionSpec<ProjectionEvent>;
2232
+
2233
+ beforeEach(() => {
2234
+ given = InMemoryProjectionSpec.for({ projection });
2235
+ });
2236
+
2237
+ it('Summary after workout recorded', () =>
2238
+ given([])
2239
+ .when([
2240
+ {
2241
+ type: 'WorkoutRecorded',
2242
+ data: {
2243
+ memberId: 'mem_001',
2244
+ caloriesBurned: 250,
2245
+ },
2246
+ metadata: {
2247
+ streamName: 'workouts',
2248
+ streamPosition: 1n,
2249
+ globalPosition: 1n,
2250
+ },
2251
+ },
2252
+ ])
2253
+ .then(async (state) => {
2254
+ const document = await state.database
2255
+ .collection<WorkoutSummary>('WorkoutSummaryProjection')
2256
+ .findOne((doc) => doc.memberId === 'mem_001');
2257
+
2258
+ const expected: WorkoutSummary = {
2259
+ totalCalories: 250,
2260
+ totalSessions: 1,
2261
+ };
2262
+
2263
+ expect(document).toMatchObject(expected);
2264
+ }));
2265
+ });
2266
+ "
2267
+ `);
2268
+ });
2096
2269
  });