@auto-engineer/server-generator-apollo-emmett 1.130.0 → 1.134.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 (48) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-test.log +6 -6
  3. package/.turbo/turbo-type-check.log +1 -1
  4. package/CHANGELOG.md +168 -0
  5. package/dist/src/codegen/extract/data-sink.d.ts +1 -0
  6. package/dist/src/codegen/extract/data-sink.d.ts.map +1 -1
  7. package/dist/src/codegen/extract/data-sink.js +9 -0
  8. package/dist/src/codegen/extract/data-sink.js.map +1 -1
  9. package/dist/src/codegen/extract/imports.d.ts +0 -5
  10. package/dist/src/codegen/extract/imports.d.ts.map +1 -1
  11. package/dist/src/codegen/extract/imports.js +0 -7
  12. package/dist/src/codegen/extract/imports.js.map +1 -1
  13. package/dist/src/codegen/extract/projection.d.ts +6 -1
  14. package/dist/src/codegen/extract/projection.d.ts.map +1 -1
  15. package/dist/src/codegen/extract/projection.js +17 -0
  16. package/dist/src/codegen/extract/projection.js.map +1 -1
  17. package/dist/src/codegen/extract/query.d.ts +8 -2
  18. package/dist/src/codegen/extract/query.d.ts.map +1 -1
  19. package/dist/src/codegen/extract/query.js +36 -0
  20. package/dist/src/codegen/extract/query.js.map +1 -1
  21. package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
  22. package/dist/src/codegen/scaffoldFromSchema.js +13 -5
  23. package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
  24. package/dist/src/codegen/templates/query/events.specs.ts +112 -0
  25. package/dist/src/codegen/templates/query/events.ts.ejs +17 -0
  26. package/dist/src/codegen/templates/query/projection.specs.ts +323 -0
  27. package/dist/src/codegen/templates/query/projection.ts.ejs +27 -4
  28. package/dist/src/codegen/templates/query/query.resolver.specs.ts +242 -0
  29. package/dist/src/codegen/templates/query/query.resolver.ts.ejs +8 -4
  30. package/dist/src/commands/generate-server.js +5 -5
  31. package/dist/src/commands/generate-server.js.map +1 -1
  32. package/dist/tsconfig.tsbuildinfo +1 -1
  33. package/package.json +4 -4
  34. package/src/codegen/extract/data-sink.specs.ts +24 -0
  35. package/src/codegen/extract/data-sink.ts +10 -0
  36. package/src/codegen/extract/imports.ts +0 -8
  37. package/src/codegen/extract/projection.specs.ts +212 -0
  38. package/src/codegen/extract/projection.ts +22 -1
  39. package/src/codegen/extract/query.specs.ts +137 -0
  40. package/src/codegen/extract/query.ts +46 -2
  41. package/src/codegen/scaffoldFromSchema.ts +18 -3
  42. package/src/codegen/templates/query/events.specs.ts +112 -0
  43. package/src/codegen/templates/query/events.ts.ejs +17 -0
  44. package/src/codegen/templates/query/projection.specs.ts +323 -0
  45. package/src/codegen/templates/query/projection.ts.ejs +27 -4
  46. package/src/codegen/templates/query/query.resolver.specs.ts +242 -0
  47. package/src/codegen/templates/query/query.resolver.ts.ejs +8 -4
  48. package/src/commands/generate-server.ts +5 -5
@@ -0,0 +1,112 @@
1
+ import type { Model } from '@auto-engineer/narrative';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { generateScaffoldFilePlans } from '../../scaffoldFromSchema';
4
+
5
+ describe('query events.ts.ejs', () => {
6
+ it('generates events.ts for orphan events not produced by any command slice', async () => {
7
+ const spec: Model = {
8
+ variant: 'specs',
9
+ narratives: [
10
+ {
11
+ name: 'appointment-flow',
12
+ slices: [
13
+ {
14
+ type: 'query',
15
+ name: 'view-appointments',
16
+ stream: 'appointments',
17
+ client: { specs: [] },
18
+ server: {
19
+ description: 'projection for booked appointments',
20
+ data: {
21
+ items: [
22
+ {
23
+ target: { type: 'State', name: 'Appointment' },
24
+ origin: {
25
+ type: 'projection',
26
+ name: 'AppointmentsProjection',
27
+ idField: 'appointmentId',
28
+ },
29
+ },
30
+ ],
31
+ },
32
+ specs: [
33
+ {
34
+ type: 'gherkin',
35
+ feature: 'View appointments query',
36
+ rules: [
37
+ {
38
+ name: 'Should project appointments',
39
+ examples: [
40
+ {
41
+ name: 'Appointment booked shows in list',
42
+ steps: [
43
+ {
44
+ keyword: 'When',
45
+ text: 'AppointmentBooked',
46
+ docString: {
47
+ appointmentId: 'appt_1',
48
+ date: '2024-06-15',
49
+ },
50
+ },
51
+ {
52
+ keyword: 'Then',
53
+ text: 'Appointment',
54
+ docString: {
55
+ appointmentId: 'appt_1',
56
+ date: '2024-06-15',
57
+ },
58
+ },
59
+ ],
60
+ },
61
+ ],
62
+ },
63
+ ],
64
+ },
65
+ ],
66
+ },
67
+ },
68
+ ],
69
+ },
70
+ ],
71
+ messages: [
72
+ {
73
+ type: 'event',
74
+ name: 'AppointmentBooked',
75
+ source: 'internal',
76
+ fields: [
77
+ { name: 'appointmentId', type: 'string', required: true },
78
+ { name: 'date', type: 'string', required: true },
79
+ ],
80
+ },
81
+ {
82
+ type: 'state',
83
+ name: 'Appointment',
84
+ fields: [
85
+ { name: 'appointmentId', type: 'string', required: true },
86
+ { name: 'date', type: 'string', required: true },
87
+ ],
88
+ },
89
+ ],
90
+ };
91
+
92
+ const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
93
+
94
+ const eventFile = plans.find((p) => p.outputPath.endsWith('view-appointments/events.ts'));
95
+ expect(eventFile).toBeDefined();
96
+ expect(eventFile?.contents).toMatchInlineSnapshot(`
97
+ "import type { Event } from '@event-driven-io/emmett';
98
+
99
+ export type AppointmentBooked = Event<
100
+ 'AppointmentBooked',
101
+ {
102
+ appointmentId: string;
103
+ date: string;
104
+ }
105
+ >;
106
+ "
107
+ `);
108
+
109
+ const projectionFile = plans.find((p) => p.outputPath.endsWith('view-appointments/projection.ts'));
110
+ expect(projectionFile?.contents).toContain("import type { AppointmentBooked } from './events'");
111
+ });
112
+ });
@@ -0,0 +1,17 @@
1
+ <%
2
+ const enumList = collectEnumNames(localEvents.flatMap(e => e.fields));
3
+ %><% if (localEvents.length) { -%>
4
+ import type { Event } from '@event-driven-io/emmett';
5
+ <% if (enumList.length > 0) { %>import { <%= enumList.join(', ') %> } from '../../../shared';
6
+ <% } %>
7
+ <% for (const event of localEvents) { -%>
8
+ export type <%= pascalCase(event.type) %> = Event<
9
+ '<%= event.type %>',
10
+ {
11
+ <% for (const field of event.fields) { -%>
12
+ <%- field.name %>: <%- toTsFieldType(field.tsType) %>;
13
+ <% } -%>
14
+ }
15
+ >;
16
+ <% } -%>
17
+ <% } -%>
@@ -1008,4 +1008,327 @@ describe('projection.ts.ejs', () => {
1008
1008
  expect(projectionFile?.contents).not.toContain('number | "pending"');
1009
1009
  expect(projectionFile?.contents).toContain('mixedStatus: /* TODO: map from event.data */ undefined as any');
1010
1010
  });
1011
+
1012
+ it('should generate switch-based getDocumentId when events come from slices with different id fields', async () => {
1013
+ const flows: Model = {
1014
+ variant: 'specs',
1015
+ narratives: [
1016
+ {
1017
+ name: 'workout-flow',
1018
+ slices: [
1019
+ {
1020
+ type: 'command',
1021
+ name: 'create-workout',
1022
+ stream: 'workouts-${id}',
1023
+ client: { specs: [] },
1024
+ server: {
1025
+ description: 'creates a workout',
1026
+ specs: [
1027
+ {
1028
+ type: 'gherkin',
1029
+ feature: 'Create workout',
1030
+ rules: [
1031
+ {
1032
+ name: 'rule',
1033
+ examples: [
1034
+ {
1035
+ name: 'success',
1036
+ steps: [
1037
+ { keyword: 'When', text: 'CreateWorkout', docString: { id: 'w1', name: 'Leg Day' } },
1038
+ { keyword: 'Then', text: 'WorkoutCreated', docString: { id: 'w1', name: 'Leg Day' } },
1039
+ ],
1040
+ },
1041
+ ],
1042
+ },
1043
+ ],
1044
+ },
1045
+ ],
1046
+ },
1047
+ },
1048
+ {
1049
+ type: 'command',
1050
+ name: 'log-exercises',
1051
+ stream: 'workouts-${workoutId}',
1052
+ client: { specs: [] },
1053
+ server: {
1054
+ description: 'logs exercises to a workout',
1055
+ specs: [
1056
+ {
1057
+ type: 'gherkin',
1058
+ feature: 'Log exercises',
1059
+ rules: [
1060
+ {
1061
+ name: 'rule',
1062
+ examples: [
1063
+ {
1064
+ name: 'success',
1065
+ steps: [
1066
+ {
1067
+ keyword: 'When',
1068
+ text: 'LogExercises',
1069
+ docString: { workoutId: 'w1', exercise: 'Squats' },
1070
+ },
1071
+ {
1072
+ keyword: 'Then',
1073
+ text: 'ExerciseLogged',
1074
+ docString: { workoutId: 'w1', exercise: 'Squats' },
1075
+ },
1076
+ ],
1077
+ },
1078
+ ],
1079
+ },
1080
+ ],
1081
+ },
1082
+ ],
1083
+ },
1084
+ },
1085
+ {
1086
+ type: 'query',
1087
+ name: 'view-workout',
1088
+ stream: 'workouts',
1089
+ client: { specs: [] },
1090
+ server: {
1091
+ description: 'projection for workout details',
1092
+ data: {
1093
+ items: [
1094
+ {
1095
+ target: { type: 'State', name: 'WorkoutView' },
1096
+ origin: {
1097
+ type: 'projection',
1098
+ name: 'WorkoutProjection',
1099
+ idField: 'id',
1100
+ },
1101
+ },
1102
+ ],
1103
+ },
1104
+ specs: [
1105
+ {
1106
+ type: 'gherkin',
1107
+ feature: 'View workout',
1108
+ rules: [
1109
+ {
1110
+ name: 'rule',
1111
+ examples: [
1112
+ {
1113
+ name: 'workout created',
1114
+ steps: [
1115
+ {
1116
+ keyword: 'When',
1117
+ text: 'WorkoutCreated',
1118
+ docString: { id: 'w1', name: 'Leg Day' },
1119
+ },
1120
+ {
1121
+ keyword: 'Then',
1122
+ text: 'WorkoutView',
1123
+ docString: { id: 'w1', name: 'Leg Day', exercises: [] },
1124
+ },
1125
+ ],
1126
+ },
1127
+ {
1128
+ name: 'exercise logged',
1129
+ steps: [
1130
+ {
1131
+ keyword: 'When',
1132
+ text: 'ExerciseLogged',
1133
+ docString: { workoutId: 'w1', exercise: 'Squats' },
1134
+ },
1135
+ {
1136
+ keyword: 'Then',
1137
+ text: 'WorkoutView',
1138
+ docString: { id: 'w1', name: 'Leg Day', exercises: ['Squats'] },
1139
+ },
1140
+ ],
1141
+ },
1142
+ ],
1143
+ },
1144
+ ],
1145
+ },
1146
+ ],
1147
+ },
1148
+ },
1149
+ ],
1150
+ },
1151
+ ],
1152
+ messages: [
1153
+ {
1154
+ type: 'command',
1155
+ name: 'CreateWorkout',
1156
+ fields: [
1157
+ { name: 'id', type: 'string', required: true },
1158
+ { name: 'name', type: 'string', required: true },
1159
+ ],
1160
+ },
1161
+ {
1162
+ type: 'command',
1163
+ name: 'LogExercises',
1164
+ fields: [
1165
+ { name: 'workoutId', type: 'string', required: true },
1166
+ { name: 'exercise', type: 'string', required: true },
1167
+ ],
1168
+ },
1169
+ {
1170
+ type: 'event',
1171
+ name: 'WorkoutCreated',
1172
+ source: 'internal',
1173
+ fields: [
1174
+ { name: 'id', type: 'string', required: true },
1175
+ { name: 'name', type: 'string', required: true },
1176
+ ],
1177
+ },
1178
+ {
1179
+ type: 'event',
1180
+ name: 'ExerciseLogged',
1181
+ source: 'internal',
1182
+ fields: [
1183
+ { name: 'workoutId', type: 'string', required: true },
1184
+ { name: 'exercise', type: 'string', required: true },
1185
+ ],
1186
+ },
1187
+ {
1188
+ type: 'state',
1189
+ name: 'WorkoutView',
1190
+ fields: [
1191
+ { name: 'id', type: 'string', required: true },
1192
+ { name: 'name', type: 'string', required: true },
1193
+ { name: 'exercises', type: 'Array<string>', required: true },
1194
+ ],
1195
+ },
1196
+ ],
1197
+ };
1198
+
1199
+ const { plans } = await generateScaffoldFilePlans(flows.narratives, flows.messages, undefined, 'src/domain/flows');
1200
+ const projectionFile = plans.find((p) => p.outputPath.endsWith('view-workout/projection.ts'));
1201
+
1202
+ expect(projectionFile?.contents).toMatchInlineSnapshot(`
1203
+ "import {
1204
+ inMemorySingleStreamProjection,
1205
+ type ReadEvent,
1206
+ type InMemoryReadEventMetadata,
1207
+ } from '@event-driven-io/emmett';
1208
+ import type { WorkoutView } from './state';
1209
+ import type { WorkoutCreated } from '../create-workout/events';
1210
+
1211
+ import type { ExerciseLogged } from '../log-exercises/events';
1212
+
1213
+ type AllEvents = ExerciseLogged | WorkoutCreated;
1214
+
1215
+ // Auto-generated — do not change this function, its imports, or type parameters.
1216
+ // Only implement the case bodies inside evolve.
1217
+ export const projection = inMemorySingleStreamProjection<WorkoutView, AllEvents>({
1218
+ collectionName: 'WorkoutProjection',
1219
+ canHandle: ['WorkoutCreated', 'ExerciseLogged'],
1220
+ getDocumentId: (event) => {
1221
+ switch (event.type) {
1222
+ case 'WorkoutCreated':
1223
+ return event.data.id;
1224
+
1225
+ case 'ExerciseLogged':
1226
+ return event.data.workoutId;
1227
+
1228
+ default:
1229
+ return event.data.id;
1230
+ }
1231
+ },
1232
+ evolve: (
1233
+ document: WorkoutView | null,
1234
+ event: ReadEvent<AllEvents, InMemoryReadEventMetadata>,
1235
+ ): WorkoutView | null => {
1236
+ switch (event.type) {
1237
+ case 'WorkoutCreated': {
1238
+ /**
1239
+ * ## IMPLEMENTATION INSTRUCTIONS ##
1240
+ *
1241
+ * Derive ALL field values from event.data or existing document state.
1242
+ * NEVER hardcode constant values — every output field must trace to an input.
1243
+ * Preserve all import paths above — they are generated from the model.
1244
+ * ⚠️ \`document\` may be null (first event for this entity). Guard before accessing properties.
1245
+ *
1246
+ * CONSTRAINTS:
1247
+ * - NEVER use \`as SomeType\` type assertions. Declare typed variables instead: \`const x: Type = value;\`
1248
+ * - Only reference event.data fields listed in the "Event fields:" line below.
1249
+ * - Do NOT modify anything outside this case block: imports, type parameters, canHandle, collectionName, and getDocumentId are auto-generated.
1250
+ *
1251
+ * Implement how this event updates the projection.
1252
+ *
1253
+ * **Internal State Pattern (extends, never replaces the imported type):**
1254
+ * If you need to track state beyond the public WorkoutView type (e.g., to calculate
1255
+ * aggregations, track previous values, etc.), you may EXTEND it with an interface (never replace it):
1256
+ *
1257
+ * 1. Define an extended interface BEFORE the projection:
1258
+ * interface InternalWorkoutView extends WorkoutView {
1259
+ * internalField: SomeType;
1260
+ * }
1261
+ *
1262
+ * 2. Assign document to extended type:
1263
+ * const current: InternalWorkoutView = document ?? { ...defaults };
1264
+ *
1265
+ * 3. Return via typed variable (preserves internal state for next event):
1266
+ * const result: InternalWorkoutView = { ...allFields, internalField };
1267
+ * return result;
1268
+ *
1269
+ * This keeps internal state separate from the public GraphQL schema.
1270
+
1271
+ * Event (WorkoutCreated) fields: id: string, name: string
1272
+ */
1273
+ return {
1274
+ ...document,
1275
+ id: /* TODO: map from event.data */ '',
1276
+ name: /* TODO: map from event.data */ '',
1277
+ exercises: /* TODO: map from event.data */ [],
1278
+ };
1279
+ }
1280
+
1281
+ case 'ExerciseLogged': {
1282
+ /**
1283
+ * ## IMPLEMENTATION INSTRUCTIONS ##
1284
+ *
1285
+ * Derive ALL field values from event.data or existing document state.
1286
+ * NEVER hardcode constant values — every output field must trace to an input.
1287
+ * Preserve all import paths above — they are generated from the model.
1288
+ * ⚠️ \`document\` may be null (first event for this entity). Guard before accessing properties.
1289
+ *
1290
+ * CONSTRAINTS:
1291
+ * - NEVER use \`as SomeType\` type assertions. Declare typed variables instead: \`const x: Type = value;\`
1292
+ * - Only reference event.data fields listed in the "Event fields:" line below.
1293
+ * - Do NOT modify anything outside this case block: imports, type parameters, canHandle, collectionName, and getDocumentId are auto-generated.
1294
+ *
1295
+ * Implement how this event updates the projection.
1296
+ *
1297
+ * **Internal State Pattern (extends, never replaces the imported type):**
1298
+ * If you need to track state beyond the public WorkoutView type (e.g., to calculate
1299
+ * aggregations, track previous values, etc.), you may EXTEND it with an interface (never replace it):
1300
+ *
1301
+ * 1. Define an extended interface BEFORE the projection:
1302
+ * interface InternalWorkoutView extends WorkoutView {
1303
+ * internalField: SomeType;
1304
+ * }
1305
+ *
1306
+ * 2. Assign document to extended type:
1307
+ * const current: InternalWorkoutView = document ?? { ...defaults };
1308
+ *
1309
+ * 3. Return via typed variable (preserves internal state for next event):
1310
+ * const result: InternalWorkoutView = { ...allFields, internalField };
1311
+ * return result;
1312
+ *
1313
+ * This keeps internal state separate from the public GraphQL schema.
1314
+
1315
+ * Event (ExerciseLogged) fields: workoutId: string, exercise: string
1316
+ */
1317
+ return {
1318
+ ...document,
1319
+ id: /* TODO: map from event.data */ '',
1320
+ name: /* TODO: map from event.data */ '',
1321
+ exercises: /* TODO: map from event.data */ [],
1322
+ };
1323
+ }
1324
+ default:
1325
+ return document;
1326
+ }
1327
+ },
1328
+ });
1329
+
1330
+ export default projection;
1331
+ "
1332
+ `);
1333
+ });
1011
1334
  });
@@ -75,22 +75,45 @@ if (isSingleton) {
75
75
  index === 0 ? `\${event.data.${field}}` : `-\${event.data.${field}}`
76
76
  ).join('');
77
77
  %>`<%= template %>`<%
78
+ } else if (eventIdFieldMap) {
79
+ const uniqueFields = [...new Set(eventIdFieldMap.values())];
80
+ if (uniqueFields.length === 1) {
81
+ %>event.data.<%= uniqueFields[0] %><%
82
+ } else {
83
+ %>{
84
+ switch (event.type) {
85
+ <% for (const [eventType, field] of eventIdFieldMap) { %>
86
+ case '<%= eventType %>': return event.data.<%= field %>;
87
+ <% } %>
88
+ default: return event.data.<%= typeof idField === 'string' ? idField : 'id' %>;
89
+ }
90
+ }<%
91
+ }
78
92
  } else {
79
93
  const singleIdField = typeof idField === 'string' ? idField : 'id';
80
94
  %>event.data.<%= singleIdField %><%
81
95
  }
82
96
  %>,
83
97
  <%
84
- if (!isSingleton && !isCompositeKey && typeof idField === 'string') {
98
+ const effectiveIdField = eventIdFieldMap
99
+ ? [...new Set(eventIdFieldMap.values())].length === 1 ? [...new Set(eventIdFieldMap.values())][0] : null
100
+ : typeof idField === 'string' ? idField : null;
101
+ if (!isSingleton && !isCompositeKey && effectiveIdField) {
85
102
  const missing = events.filter(e => {
103
+ if (eventIdFieldMap) {
104
+ const mappedField = eventIdFieldMap.get(e.type);
105
+ if (!mappedField) return false;
106
+ const def = messages.find(m => m.name === e.type);
107
+ return def && !def.fields?.some(f => f.name === mappedField);
108
+ }
86
109
  const def = messages.find(m => m.name === e.type);
87
- return def && !def.fields?.some(f => f.name === idField);
110
+ return def && !def.fields?.some(f => f.name === effectiveIdField);
88
111
  });
89
112
  if (missing.length > 0) {
90
113
  %>
91
- // WARNING: These events lack field '<%= idField %>': <%= missing.map(e => e.type).join(', ') %>
114
+ // WARNING: These events lack field '<%= effectiveIdField %>': <%= missing.map(e => e.type).join(', ') %>
92
115
  // The projection cannot route these events to the correct document.
93
- // Fix: add '<%= idField %>' to these events in the model.
116
+ // Fix: add '<%= effectiveIdField %>' to these events in the model.
94
117
  <% }
95
118
  }
96
119
  -%>