@auto-engineer/server-generator-apollo-emmett 1.124.0 → 1.125.1

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 (52) 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 +72 -0
  5. package/dist/src/codegen/extract/type-helpers.d.ts.map +1 -1
  6. package/dist/src/codegen/extract/type-helpers.js +7 -5
  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 +19 -1
  10. package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
  11. package/dist/src/codegen/templates/command/mutation.resolver.specs.ts +0 -1
  12. package/dist/src/codegen/templates/query/query.resolver.specs.ts +8 -9
  13. package/dist/src/codegen/templates/query/query.resolver.ts.ejs +9 -3
  14. package/dist/src/codegen/templates/query/state.specs.ts +63 -0
  15. package/dist/src/codegen/templates/query/state.ts.ejs +1 -1
  16. package/dist/src/codegen/templates/react/react.specs.specs.ts +3 -3
  17. package/dist/src/codegen/templates/react/react.specs.ts +2 -2
  18. package/dist/src/codegen/templates/react/react.specs.ts.ejs +2 -2
  19. package/dist/src/codegen/templates/react/react.ts.ejs +138 -64
  20. package/dist/src/codegen/templates/react/react.ts.specs.ts +243 -1
  21. package/dist/src/codegen/templates/react/register.specs.ts +281 -14
  22. package/dist/src/codegen/templates/react/register.ts.ejs +100 -48
  23. package/dist/src/commands/generate-server.d.ts +1 -0
  24. package/dist/src/commands/generate-server.d.ts.map +1 -1
  25. package/dist/src/commands/generate-server.js +18 -0
  26. package/dist/src/commands/generate-server.js.map +1 -1
  27. package/dist/src/domain/shared/reactorSpecification.d.ts +5 -5
  28. package/dist/src/domain/shared/reactorSpecification.d.ts.map +1 -1
  29. package/dist/src/domain/shared/reactorSpecification.js +1 -2
  30. package/dist/src/domain/shared/reactorSpecification.js.map +1 -1
  31. package/dist/src/domain/shared/reactorSpecification.ts +7 -10
  32. package/dist/tsconfig.tsbuildinfo +1 -1
  33. package/ketchup-plan.md +4 -30
  34. package/package.json +4 -4
  35. package/src/codegen/extract/type-helpers.specs.ts +50 -1
  36. package/src/codegen/extract/type-helpers.ts +6 -3
  37. package/src/codegen/scaffoldFromSchema.ts +21 -1
  38. package/src/codegen/templates/command/mutation.resolver.specs.ts +0 -1
  39. package/src/codegen/templates/query/query.resolver.specs.ts +8 -9
  40. package/src/codegen/templates/query/query.resolver.ts.ejs +9 -3
  41. package/src/codegen/templates/query/state.specs.ts +63 -0
  42. package/src/codegen/templates/query/state.ts.ejs +1 -1
  43. package/src/codegen/templates/react/react.specs.specs.ts +3 -3
  44. package/src/codegen/templates/react/react.specs.ts +2 -2
  45. package/src/codegen/templates/react/react.specs.ts.ejs +2 -2
  46. package/src/codegen/templates/react/react.ts.ejs +138 -64
  47. package/src/codegen/templates/react/react.ts.specs.ts +243 -1
  48. package/src/codegen/templates/react/register.specs.ts +281 -14
  49. package/src/codegen/templates/react/register.ts.ejs +100 -48
  50. package/src/commands/generate-server.specs.ts +32 -0
  51. package/src/commands/generate-server.ts +20 -0
  52. package/src/domain/shared/reactorSpecification.ts +7 -10
@@ -1,51 +1,50 @@
1
1
  <%
2
- const specs = slice.server?.specs;
3
- const firstExample = specs?.rules?.flatMap(rule => rule.examples)[0] ?? null;
4
- const gwt = firstExample ? {
5
- given: firstExample.given,
6
- when: firstExample.when,
7
- then: firstExample.then
8
- } : null;
9
- const when = Array.isArray(gwt?.when) ? gwt.when[0] : gwt?.when;
10
- const then = Array.isArray(gwt?.then) ? gwt.then[0] : gwt?.then;
2
+ if (!eventCommandPairs || eventCommandPairs.length === 0) {
3
+ throw new Error(`react.ts.ejs: slice "${slice.name}" has no event→command pairs — check specs`);
4
+ }
11
5
 
12
- const eventType = when?.eventRef;
13
- const commandType = then?.commandRef;
14
- if (!eventType) throw new Error(
15
- `react.ts.ejs: slice "${slice.name}" has no event in .when() check specs`
16
- );
17
- if (!commandType) throw new Error(
18
- `react.ts.ejs: slice "${slice.name}" has no command in .then() — check specs`
19
- );
20
- const event = events.find(e => e.type === eventType);
21
- const isCrossFlow = event?.sourceFlowName && event.sourceFlowName !== flowName;
22
- const eventImportBase = isCrossFlow
23
- ? `../../${toKebabCase(event.sourceFlowName)}/${toKebabCase(event.sourceSliceName)}`
24
- : event?.sourceSliceName ? `../${toKebabCase(event.sourceSliceName)}` : '.';
6
+ const importGroups = new Map();
7
+ for (const pair of eventCommandPairs) {
8
+ const event = events.find(e => e.type === pair.eventType);
9
+ const isCrossFlow = event?.sourceFlowName && event.sourceFlowName !== flowName;
10
+ const importBase = isCrossFlow
11
+ ? `../../${toKebabCase(event.sourceFlowName)}/${toKebabCase(event.sourceSliceName)}`
12
+ : event?.sourceSliceName ? `../${toKebabCase(event.sourceSliceName)}` : '.';
13
+ if (!importGroups.has(importBase)) importGroups.set(importBase, []);
14
+ const typeName = pascalCase(pair.eventType);
15
+ if (!importGroups.get(importBase).includes(typeName)) {
16
+ importGroups.get(importBase).push(typeName);
17
+ }
18
+ }
19
+
20
+ const allEventTypeNames = eventCommandPairs.map(p => pascalCase(p.eventType));
21
+ const uniqueEventTypeNames = [...new Set(allEventTypeNames)];
22
+ const eventUnion = uniqueEventTypeNames.join(' | ');
23
+
24
+ const willHaveAnyAggregateStream = eventCommandPairs.some(pair => {
25
+ const eventDef = messages.find(m => m.name === pair.eventType);
26
+ return states.some(state =>
27
+ findPrimitiveLinkingField(state.fields, eventDef?.fields || []) !== undefined
28
+ );
29
+ });
25
30
  -%>
26
31
  import {
27
32
  inMemoryReactor,
28
33
  type MessageHandlerResult,
29
- IllegalStateError,
30
34
  } from '@event-driven-io/emmett';
31
- import type { <%= pascalCase(eventType) %> } from '<%= eventImportBase %>/events';
35
+ <% for (const [importBase, types] of importGroups) { -%>
36
+ import type { <%= types.join(', ') %> } from '<%= importBase %>/events';
37
+ <% } -%>
32
38
  import type { ReactorContext } from '../../../shared';
33
39
 
34
40
  export const react = ({ eventStore, commandSender, database }: ReactorContext) =>
35
- inMemoryReactor<<%= pascalCase(eventType) %>>({
41
+ inMemoryReactor<<%= eventUnion %>>({
36
42
  processorId: '<%= toKebabCase(flowName) %>-<%= toKebabCase(slice.name) %>',
37
- canHandle: ['<%= eventType %>'],
43
+ canHandle: [<%= eventCommandPairs.map(p => `'${p.eventType}'`).join(', ') %>],
38
44
  connectionOptions: {
39
45
  database,
40
46
  },
41
- eachMessage: async (event, context): Promise<MessageHandlerResult> => {
42
- <%
43
- const eventDef = messages.find(m => m.name === eventType);
44
- const commandDef = messages.find(m => m.name === commandType && m.type === 'command');
45
- const willHaveAggregateStream = states.some(state =>
46
- findPrimitiveLinkingField(state.fields, eventDef?.fields || []) !== undefined
47
- );
48
- -%>
47
+ eachMessage: async (event): Promise<MessageHandlerResult> => {
49
48
  /**
50
49
  * ## IMPLEMENTATION INSTRUCTIONS ##
51
50
  *
@@ -57,7 +56,7 @@ const willHaveAggregateStream = states.some(state =>
57
56
  * NEVER hardcode values copied from test assertions.
58
57
  *
59
58
  * Preserve all import paths above — they are generated from the model.
60
- <% if (willHaveAggregateStream) { -%>
59
+ <% if (willHaveAnyAggregateStream) { -%>
61
60
  * Do NOT modify or remove aggregateStream calls — they load required state.
62
61
  <% } -%>
63
62
  *
@@ -69,31 +68,38 @@ const willHaveAggregateStream = states.some(state =>
69
68
  *
70
69
  * Add business logic (validation, conditional sends) as needed.
71
70
  */
72
- <%
73
- const commandFields = (commandDef?.fields || []);
74
- const eventFieldSet = new Set((eventDef?.fields || []).map(f => f.name));
75
- const stateFieldSources = {};
71
+ <% if (eventCommandPairs.length === 1) {
72
+ const pair = eventCommandPairs[0];
73
+ const eventDef = messages.find(m => m.name === pair.eventType);
74
+ const commandDef = messages.find(m => m.name === pair.commandType && m.type === 'command');
75
+ const commandFields = (commandDef?.fields || []);
76
+ const eventFieldSet = new Set((eventDef?.fields || []).map(f => f.name));
77
+ const stateFieldSources = {};
76
78
  -%>
77
79
  <% if (eventDef?.fields?.length) { %>
78
- // Event (<%= eventType %>) fields: <%= eventDef.fields.map(f => f.name + ': ' + (f.tsType || f.type)).join(', ') %>
80
+ // Event (<%= pair.eventType %>) fields: <%= eventDef.fields.map(f => f.name + ': ' + (f.tsType || f.type)).join(', ') %>
79
81
  <% } -%>
80
82
  <% if (commandDef?.fields?.length) { %>
81
- // Command (<%= commandType %>) fields: <%= commandDef.fields.map(f => f.name + ': ' + (f.tsType || f.type)).join(', ') %>
83
+ // Command (<%= pair.commandType %>) fields: <%= commandDef.fields.map(f => f.name + ': ' + (f.tsType || f.type)).join(', ') %>
82
84
  <% } -%>
83
85
  <% if (states.length > 0) {
84
- let hasAggregateStream = false;
85
- for (const state of states) {
86
- const linkingField = findPrimitiveLinkingField(state.fields, eventDef?.fields || []);
87
- if (linkingField) {
88
- hasAggregateStream = true;
89
- const varName = camelCase(state.type);
90
- for (const f of state.fields) {
91
- if (!stateFieldSources[f.name]) stateFieldSources[f.name] = varName;
92
- }
86
+ 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);
92
+ for (const f of state.fields) {
93
+ if (!stateFieldSources[f.name]) stateFieldSources[f.name] = varName;
94
+ }
95
+ const stateUsedByCommand = commandFields.some(f =>
96
+ !eventFieldSet.has(f.name) && state.fields.some(sf => sf.name === f.name)
97
+ );
98
+ const varPrefix = stateUsedByCommand ? '' : '_';
93
99
  -%>
94
100
 
95
- const { state: <%= varName %> } = await eventStore.aggregateStream(
96
- '<%= state.type %>-' + event.data.<%= linkingField %>,
101
+ const { state: <%= varPrefix %><%= varName %> } = await eventStore.aggregateStream(
102
+ `<%= state.type %>-${event.data.<%= linkingField %>}`,
97
103
  {
98
104
  evolve: (currentState: Record<string, unknown>, evt: { type: string; data: Record<string, unknown> }) => ({ ...currentState, ...evt.data }),
99
105
  initialState: (): Record<string, unknown> => ({}),
@@ -101,35 +107,103 @@ const stateFieldSources = {};
101
107
  );
102
108
  // <%= state.type %> fields: <%= state.fields.map(f => f.name).join(', ') %>
103
109
 
104
- <% }
105
- }
106
- if (!hasAggregateStream) {
110
+ <% }
111
+ }
112
+ if (!hasAggregateStream) {
107
113
  -%>
108
114
 
109
- <% }
110
- } else {
115
+ <% }
116
+ } else {
111
117
  -%>
112
118
 
113
119
  <% } -%>
114
120
  await commandSender.send({
115
- type: '<%= commandType %>',
121
+ type: '<%= pair.commandType %>',
116
122
  kind: 'Command',
117
123
  data: {
118
124
  <% for (const field of commandFields) {
119
- const fieldName = field.name;
120
- if (eventFieldSet.has(fieldName)) {
125
+ const fieldName = field.name;
126
+ if (eventFieldSet.has(fieldName)) {
121
127
  -%>
122
128
  <%= fieldName %>: event.data.<%= fieldName %>,
123
- <% } else if (stateFieldSources[fieldName]) { -%>
129
+ <% } else if (stateFieldSources[fieldName]) { -%>
124
130
  <%= fieldName %>: <%= stateFieldSources[fieldName] %>.<%= fieldName %>,
125
- <% } else { -%>
131
+ <% } else { -%>
126
132
  <%= fieldName %>: undefined, // TODO: source unknown
127
- <% }
128
- }
133
+ <% }
134
+ }
129
135
  -%>
130
136
  },
131
137
  });
132
138
 
133
139
  return;
140
+ <% } else {
141
+ for (let pairIdx = 0; pairIdx < eventCommandPairs.length; pairIdx++) {
142
+ const pair = eventCommandPairs[pairIdx];
143
+ const eventDef = messages.find(m => m.name === pair.eventType);
144
+ const commandDef = messages.find(m => m.name === pair.commandType && m.type === 'command');
145
+ const commandFields = (commandDef?.fields || []);
146
+ const eventFieldSet = new Set((eventDef?.fields || []).map(f => f.name));
147
+ const stateFieldSources = {};
148
+ const condition = pairIdx === 0 ? 'if' : '} else if';
149
+ -%>
150
+ <%= condition %> (event.type === '<%= pair.eventType %>') {
151
+ <% if (eventDef?.fields?.length) { %>
152
+ // Event (<%= pair.eventType %>) fields: <%= eventDef.fields.map(f => f.name + ': ' + (f.tsType || f.type)).join(', ') %>
153
+ <% } -%>
154
+ <% if (commandDef?.fields?.length) { %>
155
+ // Command (<%= pair.commandType %>) fields: <%= commandDef.fields.map(f => f.name + ': ' + (f.tsType || f.type)).join(', ') %>
156
+ <% } -%>
157
+ <% 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);
162
+ for (const f of state.fields) {
163
+ if (!stateFieldSources[f.name]) stateFieldSources[f.name] = varName;
164
+ }
165
+ const stateUsedByCommand = commandFields.some(f =>
166
+ !eventFieldSet.has(f.name) && state.fields.some(sf => sf.name === f.name)
167
+ );
168
+ const varPrefix = stateUsedByCommand ? '' : '_';
169
+ -%>
170
+
171
+ const { state: <%= varPrefix %><%= varName %> } = await eventStore.aggregateStream(
172
+ `<%= state.type %>-${event.data.<%= linkingField %>}`,
173
+ {
174
+ evolve: (currentState: Record<string, unknown>, evt: { type: string; data: Record<string, unknown> }) => ({ ...currentState, ...evt.data }),
175
+ initialState: (): Record<string, unknown> => ({}),
176
+ },
177
+ );
178
+ // <%= state.type %> fields: <%= state.fields.map(f => f.name).join(', ') %>
179
+
180
+ <% }
181
+ }
182
+ }
183
+ -%>
184
+ await commandSender.send({
185
+ type: '<%= pair.commandType %>',
186
+ kind: 'Command',
187
+ data: {
188
+ <% for (const field of commandFields) {
189
+ const fieldName = field.name;
190
+ if (eventFieldSet.has(fieldName)) {
191
+ -%>
192
+ <%= fieldName %>: event.data.<%= fieldName %>,
193
+ <% } else if (stateFieldSources[fieldName]) { -%>
194
+ <%= fieldName %>: <%= stateFieldSources[fieldName] %>.<%= fieldName %>,
195
+ <% } else { -%>
196
+ <%= fieldName %>: undefined, // TODO: source unknown
197
+ <% }
198
+ }
199
+ -%>
200
+ },
201
+ });
202
+ <% }
203
+ -%>
204
+ }
205
+
206
+ return;
207
+ <% } -%>
134
208
  },
135
209
  });
@@ -762,8 +762,250 @@ export type BarberNotified = Event<
762
762
  const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
763
763
  const reactFile = plans.find((p) => p.outputPath.endsWith('check-record-updates/react.ts'));
764
764
 
765
- expect(reactFile?.contents).toContain("'WorkoutSummary-' + event.data.memberId");
765
+ expect(reactFile?.contents).toContain('`WorkoutSummary-${event.data.memberId}`');
766
766
  expect(reactFile?.contents).toContain('aggregateStream');
767
767
  expect(reactFile?.contents).not.toContain('event.data.exercises');
768
768
  });
769
+
770
+ it('should scaffold handlers for multiple event→command pairs', async () => {
771
+ const spec: SpecsSchema = {
772
+ variant: 'specs',
773
+ narratives: [
774
+ {
775
+ name: 'fitness flow',
776
+ slices: [
777
+ {
778
+ type: 'command',
779
+ name: 'earn points',
780
+ client: { specs: [] },
781
+ server: {
782
+ description: '',
783
+ specs: [
784
+ {
785
+ type: 'gherkin',
786
+ feature: 'Earn points',
787
+ rules: [
788
+ {
789
+ name: 'Should earn',
790
+ examples: [
791
+ {
792
+ name: 'Points earned',
793
+ steps: [
794
+ { keyword: 'When', text: 'EarnPoints', docString: { memberId: 'm1', amount: 10 } },
795
+ { keyword: 'Then', text: 'PointsEarned', docString: { memberId: 'm1', amount: 10 } },
796
+ ],
797
+ },
798
+ ],
799
+ },
800
+ ],
801
+ },
802
+ ],
803
+ },
804
+ },
805
+ {
806
+ type: 'command',
807
+ name: 'update record',
808
+ client: { specs: [] },
809
+ server: {
810
+ description: '',
811
+ specs: [
812
+ {
813
+ type: 'gherkin',
814
+ feature: 'Update record',
815
+ rules: [
816
+ {
817
+ name: 'Should update',
818
+ examples: [
819
+ {
820
+ name: 'Record updated',
821
+ steps: [
822
+ { keyword: 'When', text: 'UpdateRecord', docString: { memberId: 'm1' } },
823
+ {
824
+ keyword: 'Then',
825
+ text: 'PersonalRecordUpdated',
826
+ docString: { memberId: 'm1', record: 'bench' },
827
+ },
828
+ ],
829
+ },
830
+ ],
831
+ },
832
+ ],
833
+ },
834
+ ],
835
+ },
836
+ },
837
+ {
838
+ type: 'react',
839
+ name: 'award badges',
840
+ server: {
841
+ description: 'Awards badges based on points and records',
842
+ specs: [
843
+ {
844
+ type: 'gherkin',
845
+ feature: 'Award badges',
846
+ rules: [
847
+ {
848
+ name: 'Should award on points',
849
+ examples: [
850
+ {
851
+ name: 'Badge for points',
852
+ steps: [
853
+ { keyword: 'When', text: 'PointsEarned', docString: { memberId: 'm1', amount: 100 } },
854
+ {
855
+ keyword: 'Then',
856
+ text: 'AwardBadge',
857
+ docString: { memberId: 'm1', badge: 'centurion' },
858
+ },
859
+ ],
860
+ },
861
+ ],
862
+ },
863
+ {
864
+ name: 'Should notify on record',
865
+ examples: [
866
+ {
867
+ name: 'Notification for record',
868
+ steps: [
869
+ {
870
+ keyword: 'When',
871
+ text: 'PersonalRecordUpdated',
872
+ docString: { memberId: 'm1', record: 'bench' },
873
+ },
874
+ {
875
+ keyword: 'Then',
876
+ text: 'SendRecordNotification',
877
+ docString: { memberId: 'm1', message: 'New record!' },
878
+ },
879
+ ],
880
+ },
881
+ ],
882
+ },
883
+ ],
884
+ },
885
+ ],
886
+ },
887
+ },
888
+ ],
889
+ },
890
+ ],
891
+ messages: [
892
+ {
893
+ type: 'command',
894
+ name: 'EarnPoints',
895
+ fields: [
896
+ { name: 'memberId', type: 'string', required: true },
897
+ { name: 'amount', type: 'number', required: true },
898
+ ],
899
+ },
900
+ {
901
+ type: 'event',
902
+ name: 'PointsEarned',
903
+ source: 'internal',
904
+ fields: [
905
+ { name: 'memberId', type: 'string', required: true },
906
+ { name: 'amount', type: 'number', required: true },
907
+ ],
908
+ },
909
+ {
910
+ type: 'command',
911
+ name: 'UpdateRecord',
912
+ fields: [{ name: 'memberId', type: 'string', required: true }],
913
+ },
914
+ {
915
+ type: 'event',
916
+ name: 'PersonalRecordUpdated',
917
+ source: 'internal',
918
+ fields: [
919
+ { name: 'memberId', type: 'string', required: true },
920
+ { name: 'record', type: 'string', required: true },
921
+ ],
922
+ },
923
+ {
924
+ type: 'command',
925
+ name: 'AwardBadge',
926
+ fields: [
927
+ { name: 'memberId', type: 'string', required: true },
928
+ { name: 'badge', type: 'string', required: true },
929
+ ],
930
+ },
931
+ {
932
+ type: 'command',
933
+ name: 'SendRecordNotification',
934
+ fields: [
935
+ { name: 'memberId', type: 'string', required: true },
936
+ { name: 'message', type: 'string', required: true },
937
+ ],
938
+ },
939
+ ],
940
+ };
941
+
942
+ const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
943
+ const reactFile = plans.find((p) => p.outputPath.endsWith('award-badges/react.ts'));
944
+
945
+ expect(reactFile?.contents).toMatchInlineSnapshot(`
946
+ "import { inMemoryReactor, type MessageHandlerResult } from '@event-driven-io/emmett';
947
+ import type { PointsEarned } from '../earn-points/events';
948
+ import type { PersonalRecordUpdated } from '../update-record/events';
949
+ import type { ReactorContext } from '../../../shared';
950
+
951
+ export const react = ({ eventStore, commandSender, database }: ReactorContext) =>
952
+ inMemoryReactor<PointsEarned | PersonalRecordUpdated>({
953
+ processorId: 'fitness-flow-award-badges',
954
+ canHandle: ['PointsEarned', 'PersonalRecordUpdated'],
955
+ connectionOptions: {
956
+ database,
957
+ },
958
+ eachMessage: async (event): Promise<MessageHandlerResult> => {
959
+ /**
960
+ * ## IMPLEMENTATION INSTRUCTIONS ##
961
+ *
962
+ * Complete the command send below. Field sources are pre-classified:
963
+ * - \`event.data.<field>\` → already wired from the triggering event.
964
+ * - \`<stateVar>.<field>\` → already wired from loaded aggregate state.
965
+ * - \`undefined, // TODO: source unknown\` → MUST be dynamically derived:
966
+ * compute from event.data, loaded state, or runtime logic.
967
+ * NEVER hardcode values copied from test assertions.
968
+ *
969
+ * Preserve all import paths above — they are generated from the model.
970
+ *
971
+ * CONSTRAINTS:
972
+ * - NEVER use \`as SomeType\` type assertions. Use typed variable declarations.
973
+ * - Only reference event.data fields listed in the "Event fields:" comment below. Check the type before accessing any field.
974
+ * - Do NOT modify the inMemoryReactor configuration, connectionOptions, import statements, or commandSender.send() call structure. Only fill in data fields marked TODO.
975
+ * - When event.data contains nested arrays/objects (e.g., exercises[].sets[]), iterate them to compute values. Do NOT cast arrays/objects to primitive types.
976
+ *
977
+ * Add business logic (validation, conditional sends) as needed.
978
+ */
979
+ if (event.type === 'PointsEarned') {
980
+ // Event (PointsEarned) fields: memberId: string, amount: number
981
+
982
+ // Command (AwardBadge) fields: memberId: string, badge: string
983
+ await commandSender.send({
984
+ type: 'AwardBadge',
985
+ kind: 'Command',
986
+ data: {
987
+ memberId: event.data.memberId,
988
+ badge: undefined, // TODO: source unknown
989
+ },
990
+ });
991
+ } else if (event.type === 'PersonalRecordUpdated') {
992
+ // Event (PersonalRecordUpdated) fields: memberId: string, record: string
993
+
994
+ // Command (SendRecordNotification) fields: memberId: string, message: string
995
+ await commandSender.send({
996
+ type: 'SendRecordNotification',
997
+ kind: 'Command',
998
+ data: {
999
+ memberId: event.data.memberId,
1000
+ message: undefined, // TODO: source unknown
1001
+ },
1002
+ });
1003
+ }
1004
+
1005
+ return;
1006
+ },
1007
+ });
1008
+ "
1009
+ `);
1010
+ });
769
1011
  });