@auto-engineer/server-generator-apollo-emmett 1.131.0 → 1.135.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.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +6 -6
- package/.turbo/turbo-type-check.log +1 -1
- package/CHANGELOG.md +151 -0
- package/dist/src/codegen/extract/data-sink.d.ts +1 -0
- package/dist/src/codegen/extract/data-sink.d.ts.map +1 -1
- package/dist/src/codegen/extract/data-sink.js +9 -0
- package/dist/src/codegen/extract/data-sink.js.map +1 -1
- package/dist/src/codegen/extract/projection.d.ts +6 -1
- package/dist/src/codegen/extract/projection.d.ts.map +1 -1
- package/dist/src/codegen/extract/projection.js +17 -0
- package/dist/src/codegen/extract/projection.js.map +1 -1
- package/dist/src/codegen/extract/query.d.ts +8 -2
- package/dist/src/codegen/extract/query.d.ts.map +1 -1
- package/dist/src/codegen/extract/query.js +36 -0
- package/dist/src/codegen/extract/query.js.map +1 -1
- package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
- package/dist/src/codegen/scaffoldFromSchema.js +9 -2
- package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
- package/dist/src/codegen/templates/query/projection.specs.ts +323 -0
- package/dist/src/codegen/templates/query/projection.ts.ejs +27 -4
- package/dist/src/codegen/templates/query/query.resolver.specs.ts +242 -0
- package/dist/src/codegen/templates/query/query.resolver.ts.ejs +8 -4
- package/dist/src/commands/generate-server.js +5 -5
- package/dist/src/commands/generate-server.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/codegen/extract/data-sink.specs.ts +24 -0
- package/src/codegen/extract/data-sink.ts +10 -0
- package/src/codegen/extract/projection.specs.ts +212 -0
- package/src/codegen/extract/projection.ts +22 -1
- package/src/codegen/extract/query.specs.ts +137 -0
- package/src/codegen/extract/query.ts +46 -2
- package/src/codegen/scaffoldFromSchema.ts +13 -0
- package/src/codegen/templates/query/projection.specs.ts +323 -0
- package/src/codegen/templates/query/projection.ts.ejs +27 -4
- package/src/codegen/templates/query/query.resolver.specs.ts +242 -0
- package/src/codegen/templates/query/query.resolver.ts.ejs +8 -4
- package/src/commands/generate-server.ts +5 -5
|
@@ -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
|
-
|
|
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 ===
|
|
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 '<%=
|
|
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 '<%=
|
|
116
|
+
// Fix: add '<%= effectiveIdField %>' to these events in the model.
|
|
94
117
|
<% }
|
|
95
118
|
}
|
|
96
119
|
-%>
|
|
@@ -1250,4 +1250,246 @@ describe('query.resolver.ts.ejs', () => {
|
|
|
1250
1250
|
expect(resolverFile?.contents).toContain("@Arg('limit', () => Float, { nullable: true }) _limit?: number");
|
|
1251
1251
|
expect(resolverFile?.contents).not.toContain('ListWorkoutsFilterInput');
|
|
1252
1252
|
});
|
|
1253
|
+
|
|
1254
|
+
it('should map query arg to state field via value matching when names differ', async () => {
|
|
1255
|
+
const spec: SpecsSchema = {
|
|
1256
|
+
variant: 'specs',
|
|
1257
|
+
narratives: [
|
|
1258
|
+
{
|
|
1259
|
+
name: 'workout-flow',
|
|
1260
|
+
slices: [
|
|
1261
|
+
{
|
|
1262
|
+
type: 'command',
|
|
1263
|
+
name: 'create-workout',
|
|
1264
|
+
stream: 'workouts-${id}',
|
|
1265
|
+
client: { specs: [] },
|
|
1266
|
+
server: {
|
|
1267
|
+
description: '',
|
|
1268
|
+
specs: [
|
|
1269
|
+
{
|
|
1270
|
+
type: 'gherkin',
|
|
1271
|
+
feature: 'Create workout',
|
|
1272
|
+
rules: [
|
|
1273
|
+
{
|
|
1274
|
+
name: 'rule',
|
|
1275
|
+
examples: [
|
|
1276
|
+
{
|
|
1277
|
+
name: 'success',
|
|
1278
|
+
steps: [
|
|
1279
|
+
{ keyword: 'When', text: 'CreateWorkout', docString: { id: 'w1', name: 'Leg Day' } },
|
|
1280
|
+
{ keyword: 'Then', text: 'WorkoutCreated', docString: { id: 'w1', name: 'Leg Day' } },
|
|
1281
|
+
],
|
|
1282
|
+
},
|
|
1283
|
+
],
|
|
1284
|
+
},
|
|
1285
|
+
],
|
|
1286
|
+
},
|
|
1287
|
+
],
|
|
1288
|
+
},
|
|
1289
|
+
},
|
|
1290
|
+
{
|
|
1291
|
+
type: 'query',
|
|
1292
|
+
name: 'view-workout',
|
|
1293
|
+
request: `
|
|
1294
|
+
query GetWorkout($workoutId: ID!) {
|
|
1295
|
+
workout(workoutId: $workoutId) {
|
|
1296
|
+
id
|
|
1297
|
+
name
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
`,
|
|
1301
|
+
stream: 'workouts',
|
|
1302
|
+
client: { specs: [] },
|
|
1303
|
+
server: {
|
|
1304
|
+
description: '',
|
|
1305
|
+
data: {
|
|
1306
|
+
items: [
|
|
1307
|
+
{
|
|
1308
|
+
target: { type: 'State', name: 'WorkoutView' },
|
|
1309
|
+
origin: { type: 'projection', name: 'WorkoutProjection', idField: 'id' },
|
|
1310
|
+
},
|
|
1311
|
+
],
|
|
1312
|
+
},
|
|
1313
|
+
specs: [
|
|
1314
|
+
{
|
|
1315
|
+
type: 'gherkin',
|
|
1316
|
+
feature: 'View workout',
|
|
1317
|
+
rules: [
|
|
1318
|
+
{
|
|
1319
|
+
name: 'rule',
|
|
1320
|
+
examples: [
|
|
1321
|
+
{
|
|
1322
|
+
name: 'fetch workout by id',
|
|
1323
|
+
steps: [
|
|
1324
|
+
{
|
|
1325
|
+
keyword: 'When',
|
|
1326
|
+
text: 'GetWorkout',
|
|
1327
|
+
docString: { workoutId: 'w1' },
|
|
1328
|
+
},
|
|
1329
|
+
{
|
|
1330
|
+
keyword: 'Then',
|
|
1331
|
+
text: 'WorkoutView',
|
|
1332
|
+
docString: { id: 'w1', name: 'Leg Day' },
|
|
1333
|
+
},
|
|
1334
|
+
],
|
|
1335
|
+
},
|
|
1336
|
+
],
|
|
1337
|
+
},
|
|
1338
|
+
],
|
|
1339
|
+
},
|
|
1340
|
+
],
|
|
1341
|
+
},
|
|
1342
|
+
},
|
|
1343
|
+
],
|
|
1344
|
+
},
|
|
1345
|
+
],
|
|
1346
|
+
messages: [
|
|
1347
|
+
{
|
|
1348
|
+
type: 'command',
|
|
1349
|
+
name: 'CreateWorkout',
|
|
1350
|
+
fields: [
|
|
1351
|
+
{ name: 'id', type: 'string', required: true },
|
|
1352
|
+
{ name: 'name', type: 'string', required: true },
|
|
1353
|
+
],
|
|
1354
|
+
},
|
|
1355
|
+
{
|
|
1356
|
+
type: 'event',
|
|
1357
|
+
name: 'WorkoutCreated',
|
|
1358
|
+
source: 'internal',
|
|
1359
|
+
fields: [
|
|
1360
|
+
{ name: 'id', type: 'string', required: true },
|
|
1361
|
+
{ name: 'name', type: 'string', required: true },
|
|
1362
|
+
],
|
|
1363
|
+
},
|
|
1364
|
+
{
|
|
1365
|
+
type: 'state',
|
|
1366
|
+
name: 'WorkoutView',
|
|
1367
|
+
fields: [
|
|
1368
|
+
{ name: 'id', type: 'string', required: true },
|
|
1369
|
+
{ name: 'name', type: 'string', required: true },
|
|
1370
|
+
],
|
|
1371
|
+
},
|
|
1372
|
+
],
|
|
1373
|
+
};
|
|
1374
|
+
|
|
1375
|
+
const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
|
|
1376
|
+
const resolverFile = plans.find((p) => p.outputPath.endsWith('view-workout/query.resolver.ts'));
|
|
1377
|
+
|
|
1378
|
+
expect(resolverFile?.contents).toMatchInlineSnapshot(`
|
|
1379
|
+
"import { Query, Resolver, Arg, Ctx, ObjectType, Field, ID } from 'type-graphql';
|
|
1380
|
+
import { type GraphQLContext, ReadModel } from '../../../shared';
|
|
1381
|
+
|
|
1382
|
+
@ObjectType()
|
|
1383
|
+
export class WorkoutView {
|
|
1384
|
+
@Field(() => String)
|
|
1385
|
+
id!: string;
|
|
1386
|
+
|
|
1387
|
+
@Field(() => String)
|
|
1388
|
+
name!: string;
|
|
1389
|
+
|
|
1390
|
+
// IMPORTANT: Index signature required for ReadModel<T extends Record<string, unknown>> compatibility
|
|
1391
|
+
[key: string]: unknown;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
@Resolver()
|
|
1395
|
+
export class ViewWorkoutQueryResolver {
|
|
1396
|
+
@Query(() => [WorkoutView])
|
|
1397
|
+
async workout(
|
|
1398
|
+
@Ctx() ctx: GraphQLContext,
|
|
1399
|
+
@Arg('workoutId', () => ID, { nullable: true }) workoutId?: string,
|
|
1400
|
+
): Promise<WorkoutView[]> {
|
|
1401
|
+
const model = new ReadModel<WorkoutView>(ctx.database, 'WorkoutProjection');
|
|
1402
|
+
|
|
1403
|
+
// ReadModel API:
|
|
1404
|
+
// - model.find(filterFn?) → T[] returns all matches (or all documents if no filter)
|
|
1405
|
+
// - model.findOne(filterFn) → T | null returns the first match
|
|
1406
|
+
//
|
|
1407
|
+
// The scaffolded code below uses find() returning an array.
|
|
1408
|
+
// If this query should return a single item, switch to findOne().
|
|
1409
|
+
|
|
1410
|
+
return model.find((item) => {
|
|
1411
|
+
if (workoutId !== undefined && item.id !== workoutId) return false;
|
|
1412
|
+
|
|
1413
|
+
return true;
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
"
|
|
1418
|
+
`);
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it('should generate operator-based filters from explicit mappings', async () => {
|
|
1422
|
+
const spec: SpecsSchema = {
|
|
1423
|
+
variant: 'specs',
|
|
1424
|
+
narratives: [
|
|
1425
|
+
{
|
|
1426
|
+
name: 'rental-flow',
|
|
1427
|
+
slices: [
|
|
1428
|
+
{
|
|
1429
|
+
type: 'query',
|
|
1430
|
+
name: 'search-listings',
|
|
1431
|
+
request: `
|
|
1432
|
+
query SearchListings($minBedrooms: Int, $maxPrice: Float) {
|
|
1433
|
+
searchListings(minBedrooms: $minBedrooms, maxPrice: $maxPrice) {
|
|
1434
|
+
listingId
|
|
1435
|
+
numBedrooms
|
|
1436
|
+
pricePerNight
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
`,
|
|
1440
|
+
mappings: [
|
|
1441
|
+
{
|
|
1442
|
+
source: { type: 'Query', name: 'SearchListings', field: 'minBedrooms' },
|
|
1443
|
+
target: { type: 'State', name: 'ListingResult', field: 'numBedrooms' },
|
|
1444
|
+
operator: 'gte',
|
|
1445
|
+
},
|
|
1446
|
+
{
|
|
1447
|
+
source: { type: 'Query', name: 'SearchListings', field: 'maxPrice' },
|
|
1448
|
+
target: { type: 'State', name: 'ListingResult', field: 'pricePerNight' },
|
|
1449
|
+
operator: 'lte',
|
|
1450
|
+
},
|
|
1451
|
+
],
|
|
1452
|
+
client: { specs: [] },
|
|
1453
|
+
server: {
|
|
1454
|
+
description: '',
|
|
1455
|
+
data: {
|
|
1456
|
+
items: [
|
|
1457
|
+
{
|
|
1458
|
+
origin: {
|
|
1459
|
+
type: 'projection',
|
|
1460
|
+
idField: 'listingId',
|
|
1461
|
+
name: 'ListingsProjection',
|
|
1462
|
+
},
|
|
1463
|
+
target: {
|
|
1464
|
+
type: 'State',
|
|
1465
|
+
name: 'ListingResult',
|
|
1466
|
+
},
|
|
1467
|
+
},
|
|
1468
|
+
],
|
|
1469
|
+
},
|
|
1470
|
+
specs: [],
|
|
1471
|
+
},
|
|
1472
|
+
},
|
|
1473
|
+
],
|
|
1474
|
+
},
|
|
1475
|
+
],
|
|
1476
|
+
messages: [
|
|
1477
|
+
{
|
|
1478
|
+
type: 'state',
|
|
1479
|
+
name: 'ListingResult',
|
|
1480
|
+
fields: [
|
|
1481
|
+
{ name: 'listingId', type: 'string', required: true },
|
|
1482
|
+
{ name: 'numBedrooms', type: 'number', required: true },
|
|
1483
|
+
{ name: 'pricePerNight', type: 'number', required: true },
|
|
1484
|
+
],
|
|
1485
|
+
},
|
|
1486
|
+
],
|
|
1487
|
+
};
|
|
1488
|
+
|
|
1489
|
+
const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
|
|
1490
|
+
const resolverFile = plans.find((p) => p.outputPath.endsWith('query.resolver.ts'));
|
|
1491
|
+
|
|
1492
|
+
expect(resolverFile?.contents).toContain('item.numBedrooms < minBedrooms');
|
|
1493
|
+
expect(resolverFile?.contents).toContain('item.pricePerNight > maxPrice');
|
|
1494
|
+
});
|
|
1253
1495
|
});
|
|
@@ -37,9 +37,11 @@ for (const field of messageFields) {
|
|
|
37
37
|
const hasArgs = parsedRequest?.args?.length > 0;
|
|
38
38
|
|
|
39
39
|
const stateFieldNames = new Set(messageFields.map(f => f.name));
|
|
40
|
+
const argFieldMap = argToStateFieldMap ?? {};
|
|
41
|
+
const negatedOp = { eq: '!==', ne: '===', gt: '<=', gte: '<', lt: '>=', lte: '>' };
|
|
40
42
|
const usedArgNames = isSingleton
|
|
41
43
|
? new Set()
|
|
42
|
-
: new Set((parsedRequest?.args ?? []).filter(a => stateFieldNames.has(a.name)).map(a => a.name));
|
|
44
|
+
: new Set((parsedRequest?.args ?? []).filter(a => stateFieldNames.has(a.name) || argFieldMap[a.name]).map(a => a.name));
|
|
43
45
|
const hasMatchingArgs = usedArgNames.size > 0;
|
|
44
46
|
|
|
45
47
|
const resolveArgTypes = (arg) => {
|
|
@@ -180,10 +182,12 @@ const model = new ReadModel<<%= viewType %>>(ctx.database, '<%= collectionName %
|
|
|
180
182
|
|
|
181
183
|
return model.find((<%= hasMatchingArgs ? 'item' : '_item' %>) => {
|
|
182
184
|
<% if (parsedRequest?.args?.length) {
|
|
183
|
-
const stateFieldNames = new Set(messageFields.map(f => f.name));
|
|
184
185
|
for (const arg of parsedRequest.args) {
|
|
185
|
-
|
|
186
|
-
|
|
186
|
+
const mapping = argFieldMap[arg.name];
|
|
187
|
+
const mappedField = mapping ? mapping.field : (stateFieldNames.has(arg.name) ? arg.name : null);
|
|
188
|
+
const op = mapping ? negatedOp[mapping.operator] : '!==';
|
|
189
|
+
if (mappedField) { %>
|
|
190
|
+
if (<%= arg.name %> !== undefined && item.<%= mappedField %> <%= op %> <%= arg.name %>) return false;
|
|
187
191
|
<% } else { %>
|
|
188
192
|
// NOTE: '<%= arg.name %>' has no matching field on the state type — add custom filter logic if needed.
|
|
189
193
|
<% }
|
|
@@ -117,14 +117,14 @@ export type GenerateServerEvents =
|
|
|
117
117
|
|
|
118
118
|
export const commandHandler = defineCommandHandler({
|
|
119
119
|
name: 'GenerateServer',
|
|
120
|
-
displayName: '
|
|
121
|
-
alias: '
|
|
120
|
+
displayName: 'Build Back End',
|
|
121
|
+
alias: 'build:backend',
|
|
122
122
|
description: 'Generate server from model',
|
|
123
123
|
category: 'generate',
|
|
124
124
|
icon: 'server',
|
|
125
125
|
events: [
|
|
126
|
-
{ name: 'ServerGenerated', displayName: '
|
|
127
|
-
{ name: 'ServerGenerationFailed', displayName: '
|
|
126
|
+
{ name: 'ServerGenerated', displayName: 'Back End Built' },
|
|
127
|
+
{ name: 'ServerGenerationFailed', displayName: 'Back End Build Failed' },
|
|
128
128
|
{ name: 'SliceGenerated', displayName: 'Slice Generated' },
|
|
129
129
|
{ name: 'SliceGenerationFailed', displayName: 'Slice Generation Failed' },
|
|
130
130
|
],
|
|
@@ -138,7 +138,7 @@ export const commandHandler = defineCommandHandler({
|
|
|
138
138
|
required: true,
|
|
139
139
|
},
|
|
140
140
|
},
|
|
141
|
-
examples: ['$ auto
|
|
141
|
+
examples: ['$ auto build:backend --destination=.'],
|
|
142
142
|
handle: async (command: Command): Promise<GenerateServerEvents | GenerateServerEvents[]> => {
|
|
143
143
|
const typedCommand = command as GenerateServerCommand;
|
|
144
144
|
const result = await handleGenerateServerCommandInternal(typedCommand);
|