@auto-engineer/narrative 1.88.0 → 1.90.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.
@@ -0,0 +1,298 @@
1
+ import { type DocumentNode, type OperationDefinitionNode, parse, type SelectionSetNode, type TypeNode } from 'graphql';
2
+ import { convertJsonAstToSdl } from './parse-graphql-request';
3
+ import type { CommandSlice, Model, QuerySlice } from './schema';
4
+
5
+ export interface SliceRequestValidationError {
6
+ type:
7
+ | 'request_parse_error'
8
+ | 'mutation_wrong_operation_type'
9
+ | 'mutation_missing_input_arg'
10
+ | 'mutation_input_type_mismatch'
11
+ | 'mutation_message_not_found'
12
+ | 'query_wrong_operation_type'
13
+ | 'query_state_not_found'
14
+ | 'query_field_not_found'
15
+ | 'query_nested_field_not_found';
16
+ message: string;
17
+ flowName: string;
18
+ sliceName: string;
19
+ }
20
+
21
+ type ParseResult = { ok: true; operation: OperationDefinitionNode } | { ok: false; error: string };
22
+
23
+ function parseRequestSafe(request: string): ParseResult {
24
+ const sdl = convertJsonAstToSdl(request);
25
+
26
+ let ast: DocumentNode;
27
+ try {
28
+ ast = parse(sdl);
29
+ } catch (e) {
30
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
31
+ }
32
+
33
+ const op = ast.definitions.find((d): d is OperationDefinitionNode => d.kind === 'OperationDefinition');
34
+
35
+ if (!op) {
36
+ return { ok: false, error: 'No operation found in request' };
37
+ }
38
+
39
+ if (!op.name) {
40
+ return { ok: false, error: 'Operation must have a name' };
41
+ }
42
+
43
+ return { ok: true, operation: op };
44
+ }
45
+
46
+ function validateSlice(
47
+ slice: CommandSlice | QuerySlice,
48
+ model: Model,
49
+ flowName: string,
50
+ ): SliceRequestValidationError[] {
51
+ if (!slice.request || slice.request.trim() === '') return [];
52
+
53
+ const sliceName = slice.name;
54
+ const parsed = parseRequestSafe(slice.request);
55
+
56
+ if (!parsed.ok) {
57
+ return [{ type: 'request_parse_error', message: parsed.error, flowName, sliceName }];
58
+ }
59
+
60
+ if (slice.type === 'command') {
61
+ return validateMutationRequest(parsed.operation, model, flowName, sliceName);
62
+ }
63
+ return validateQueryRequest(parsed.operation, model, slice, flowName, sliceName);
64
+ }
65
+
66
+ export function validateSliceRequests(model: Model): SliceRequestValidationError[] {
67
+ const errors: SliceRequestValidationError[] = [];
68
+
69
+ for (const narrative of model.narratives) {
70
+ for (const slice of narrative.slices) {
71
+ if (slice.type !== 'command' && slice.type !== 'query') continue;
72
+ errors.push(...validateSlice(slice, model, narrative.name));
73
+ }
74
+ }
75
+
76
+ return errors;
77
+ }
78
+
79
+ function unwrapType(typeNode: TypeNode): string {
80
+ if (typeNode.kind === 'NamedType') return typeNode.name.value;
81
+ return unwrapType(typeNode.type);
82
+ }
83
+
84
+ function validateMutationRequest(
85
+ operation: OperationDefinitionNode,
86
+ model: Model,
87
+ flowName: string,
88
+ sliceName: string,
89
+ ): SliceRequestValidationError[] {
90
+ const errors: SliceRequestValidationError[] = [];
91
+ const operationName = operation.name!.value;
92
+
93
+ if (operation.operation !== 'mutation') {
94
+ errors.push({
95
+ type: 'mutation_wrong_operation_type',
96
+ message: `Command slice '${sliceName}' request should be a mutation, but found ${operation.operation}`,
97
+ flowName,
98
+ sliceName,
99
+ });
100
+ return errors;
101
+ }
102
+
103
+ const inputVar = (operation.variableDefinitions ?? []).find((v) => v.variable.name.value === 'input');
104
+
105
+ if (!inputVar) {
106
+ errors.push({
107
+ type: 'mutation_missing_input_arg',
108
+ message: `Mutation '${operationName}' is missing required $input variable`,
109
+ flowName,
110
+ sliceName,
111
+ });
112
+ return errors;
113
+ }
114
+
115
+ const inputTypeName = unwrapType(inputVar.type);
116
+ const expectedTypeName = `${operationName}Input`;
117
+
118
+ if (inputTypeName !== expectedTypeName) {
119
+ errors.push({
120
+ type: 'mutation_input_type_mismatch',
121
+ message: `Mutation '${operationName}' input type should be '${expectedTypeName}', but found '${inputTypeName}'`,
122
+ flowName,
123
+ sliceName,
124
+ });
125
+ return errors;
126
+ }
127
+
128
+ const commandExists = model.messages.some((m) => m.type === 'command' && m.name === operationName);
129
+
130
+ if (!commandExists) {
131
+ errors.push({
132
+ type: 'mutation_message_not_found',
133
+ message: `No command message '${operationName}' found in model.messages`,
134
+ flowName,
135
+ sliceName,
136
+ });
137
+ }
138
+
139
+ return errors;
140
+ }
141
+
142
+ function isInlineObject(ts: string): boolean {
143
+ return /^\{[\s\S]*\}$/.test(ts.trim());
144
+ }
145
+
146
+ function isInlineObjectArray(ts: string): boolean {
147
+ const t = ts.trim();
148
+ return /^Array<\{[\s\S]*\}>$/.test(t) || /^\{[\s\S]*\}\[\]$/.test(t);
149
+ }
150
+
151
+ function extractObjectBody(tsType: string): string | null {
152
+ const t = tsType.trim();
153
+ let inner: string;
154
+ if (t.startsWith('Array<{') && t.endsWith('}>')) {
155
+ inner = t.slice(6, -1);
156
+ } else if (t.endsWith('[]')) {
157
+ inner = t.slice(0, -2).trim();
158
+ } else {
159
+ inner = t;
160
+ }
161
+ const match = inner.match(/^\{([\s\S]*)\}$/);
162
+ return match ? match[1] : null;
163
+ }
164
+
165
+ function splitFieldsRespectingNesting(body: string): string[] {
166
+ const fields: string[] = [];
167
+ let current = '';
168
+ let depth = 0;
169
+ for (const char of body) {
170
+ if (char === '<' || char === '{') depth++;
171
+ if (char === '>' || char === '}') depth--;
172
+ if ((char === ';' || char === ',') && depth === 0) {
173
+ const trimmed = current.trim();
174
+ if (trimmed) fields.push(trimmed);
175
+ current = '';
176
+ continue;
177
+ }
178
+ current += char;
179
+ }
180
+ const trimmed = current.trim();
181
+ if (trimmed) fields.push(trimmed);
182
+ return fields;
183
+ }
184
+
185
+ function extractInlineFieldNames(tsType: string): string[] {
186
+ const body = extractObjectBody(tsType);
187
+ if (body === null) return [];
188
+ return splitFieldsRespectingNesting(body)
189
+ .map((part) => part.split(':')[0].trim())
190
+ .filter((name) => name.length > 0);
191
+ }
192
+
193
+ function resolveNestedFieldNames(fieldType: string, model: Model): string[] | null {
194
+ const trimmed = fieldType.trim();
195
+ if (isInlineObject(trimmed) || isInlineObjectArray(trimmed)) {
196
+ return extractInlineFieldNames(trimmed);
197
+ }
198
+ const referencedMessage = model.messages.find((m) => m.name === trimmed);
199
+ if (referencedMessage) {
200
+ return referencedMessage.fields.map((f) => f.name);
201
+ }
202
+ return null;
203
+ }
204
+
205
+ interface SelectionField {
206
+ name: string;
207
+ children?: SelectionField[];
208
+ }
209
+
210
+ function extractSelections(selectionSet: SelectionSetNode): SelectionField[] {
211
+ const fields: SelectionField[] = [];
212
+ for (const sel of selectionSet.selections) {
213
+ if (sel.kind !== 'Field') continue;
214
+ if (sel.name.value === '__typename') continue;
215
+ const field: SelectionField = { name: sel.name.value };
216
+ if (sel.selectionSet) {
217
+ field.children = extractSelections(sel.selectionSet);
218
+ }
219
+ fields.push(field);
220
+ }
221
+ return fields;
222
+ }
223
+
224
+ function validateQueryRequest(
225
+ operation: OperationDefinitionNode,
226
+ model: Model,
227
+ slice: QuerySlice,
228
+ flowName: string,
229
+ sliceName: string,
230
+ ): SliceRequestValidationError[] {
231
+ const errors: SliceRequestValidationError[] = [];
232
+ const operationName = operation.name!.value;
233
+
234
+ if (operation.operation !== 'query') {
235
+ errors.push({
236
+ type: 'query_wrong_operation_type',
237
+ message: `Query slice '${sliceName}' request should be a query, but found ${operation.operation}`,
238
+ flowName,
239
+ sliceName,
240
+ });
241
+ return errors;
242
+ }
243
+
244
+ const targetName = slice.server?.data?.items?.[0]?.target?.name;
245
+ if (!targetName) return errors;
246
+
247
+ const stateMessage = model.messages.find((m) => m.type === 'state' && m.name === targetName);
248
+
249
+ if (!stateMessage) {
250
+ errors.push({
251
+ type: 'query_state_not_found',
252
+ message: `State '${targetName}' referenced by query '${operationName}' not found in model.messages`,
253
+ flowName,
254
+ sliceName,
255
+ });
256
+ return errors;
257
+ }
258
+
259
+ const rootSelection = operation.selectionSet.selections[0];
260
+ if (!rootSelection || rootSelection.kind !== 'Field' || !rootSelection.selectionSet) return errors;
261
+
262
+ const selectedFields = extractSelections(rootSelection.selectionSet);
263
+ const stateFieldNames = new Set(stateMessage.fields.map((f) => f.name));
264
+
265
+ for (const sel of selectedFields) {
266
+ if (!stateFieldNames.has(sel.name)) {
267
+ errors.push({
268
+ type: 'query_field_not_found',
269
+ message: `Field '${sel.name}' in query '${operationName}' not found on state '${targetName}'`,
270
+ flowName,
271
+ sliceName,
272
+ });
273
+ continue;
274
+ }
275
+
276
+ if (sel.children && sel.children.length > 0) {
277
+ const stateField = stateMessage.fields.find((f) => f.name === sel.name);
278
+ if (!stateField) continue;
279
+
280
+ const nestedNames = resolveNestedFieldNames(stateField.type, model);
281
+ if (nestedNames === null) continue;
282
+
283
+ const nestedNameSet = new Set(nestedNames);
284
+ for (const child of sel.children) {
285
+ if (!nestedNameSet.has(child.name)) {
286
+ errors.push({
287
+ type: 'query_nested_field_not_found',
288
+ message: `Nested field '${child.name}' in query '${operationName}' not found on type of '${sel.name}'`,
289
+ flowName,
290
+ sliceName,
291
+ });
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ return errors;
298
+ }