@auto-engineer/server-generator-apollo-emmett 1.36.3 → 1.37.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 +26 -0
- package/dist/src/codegen/extract/messages.d.ts.map +1 -1
- package/dist/src/codegen/extract/messages.js +29 -3
- package/dist/src/codegen/extract/messages.js.map +1 -1
- package/dist/src/codegen/scaffoldFromSchema.d.ts +4 -0
- package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
- package/dist/src/codegen/scaffoldFromSchema.js +16 -8
- package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
- package/dist/src/codegen/templates/query/projection.specs.specs.ts +126 -0
- package/dist/src/codegen/templates/query/projection.specs.ts.ejs +8 -0
- package/dist/src/codegen/templates/react/events.ts.ejs +17 -0
- package/dist/src/codegen/templates/react/react.ts.specs.ts +126 -0
- package/dist/src/codegen/templates/react/register.ts.ejs +3 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/ketchup-plan.md +6 -7
- package/package.json +4 -4
- package/src/codegen/extract/messages.specs.ts +273 -0
- package/src/codegen/extract/messages.ts +33 -3
- package/src/codegen/findEventSource.specs.ts +117 -0
- package/src/codegen/scaffoldFromSchema.ts +16 -8
- package/src/codegen/templates/query/projection.specs.specs.ts +126 -0
- package/src/codegen/templates/query/projection.specs.ts.ejs +8 -0
- package/src/codegen/templates/react/events.ts.ejs +17 -0
- package/src/codegen/templates/react/react.ts.specs.ts +126 -0
- package/src/codegen/templates/react/register.ts.ejs +3 -0
package/ketchup-plan.md
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
# Ketchup Plan:
|
|
1
|
+
# Ketchup Plan: Fix Event Resolution Bugs
|
|
2
2
|
|
|
3
3
|
## TODO
|
|
4
4
|
|
|
5
5
|
## DONE
|
|
6
6
|
|
|
7
|
-
- [x] Burst
|
|
8
|
-
- [x] Burst
|
|
9
|
-
- [x] Burst
|
|
10
|
-
- [x] Burst
|
|
11
|
-
- [x] Burst
|
|
12
|
-
- [x] Burst 5: Wire cleanServerDir into full generation path, replacing fs.remove
|
|
7
|
+
- [x] Burst 1: Command slices — Given-step state refs get local event type definitions (261a0d41)
|
|
8
|
+
- [x] Burst 2: findEventSource — check react data target events (9ac929f9)
|
|
9
|
+
- [x] Burst 3: extractMessagesForReact — extract data target events with source 'then' (f7aee89b)
|
|
10
|
+
- [x] Burst 4: Add events.ts.ejs template for react slices + defaultFilesByType entry (d2e5e5b8)
|
|
11
|
+
- [x] Burst 5: formatSpecValue — handle stringified JSON arrays (6cb406c3)
|
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.
|
|
36
|
-
"@auto-engineer/message-bus": "1.
|
|
35
|
+
"@auto-engineer/narrative": "1.37.0",
|
|
36
|
+
"@auto-engineer/message-bus": "1.37.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.
|
|
47
|
+
"@auto-engineer/cli": "1.37.0"
|
|
48
48
|
},
|
|
49
|
-
"version": "1.
|
|
49
|
+
"version": "1.37.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",
|
|
@@ -188,3 +188,276 @@ describe('extractMessagesFromSpecs (react slice)', () => {
|
|
|
188
188
|
]);
|
|
189
189
|
});
|
|
190
190
|
});
|
|
191
|
+
|
|
192
|
+
describe('extractMessagesFromSpecs (command slice)', () => {
|
|
193
|
+
it('should create state-as-event with source then for Given-step state refs', () => {
|
|
194
|
+
const slice: Slice = {
|
|
195
|
+
type: 'command',
|
|
196
|
+
name: 'book barber appointment',
|
|
197
|
+
server: {
|
|
198
|
+
description: 'Books a barber appointment',
|
|
199
|
+
specs: [
|
|
200
|
+
{
|
|
201
|
+
type: 'gherkin',
|
|
202
|
+
feature: 'Book barber appointment',
|
|
203
|
+
rules: [
|
|
204
|
+
{
|
|
205
|
+
name: 'Should book appointment',
|
|
206
|
+
examples: [
|
|
207
|
+
{
|
|
208
|
+
name: 'Appointment booked',
|
|
209
|
+
steps: [
|
|
210
|
+
{
|
|
211
|
+
keyword: 'Given',
|
|
212
|
+
text: 'CustomerAppointments',
|
|
213
|
+
docString: { customerId: 'c1', appointments: [] },
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
keyword: 'When',
|
|
217
|
+
text: 'BookBarberAppointment',
|
|
218
|
+
docString: { customerId: 'c1', barberId: 'b1' },
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
keyword: 'Then',
|
|
222
|
+
text: 'BarberAppointmentBooked',
|
|
223
|
+
docString: { customerId: 'c1', barberId: 'b1' },
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const allMessages: MessageDefinition[] = [
|
|
236
|
+
{
|
|
237
|
+
type: 'state',
|
|
238
|
+
name: 'CustomerAppointments',
|
|
239
|
+
fields: [
|
|
240
|
+
{ name: 'customerId', type: 'string', required: true },
|
|
241
|
+
{ name: 'appointments', type: 'object[]', required: true },
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
type: 'command',
|
|
246
|
+
name: 'BookBarberAppointment',
|
|
247
|
+
fields: [
|
|
248
|
+
{ name: 'customerId', type: 'string', required: true },
|
|
249
|
+
{ name: 'barberId', type: 'string', required: true },
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
type: 'event',
|
|
254
|
+
name: 'BarberAppointmentBooked',
|
|
255
|
+
fields: [
|
|
256
|
+
{ name: 'customerId', type: 'string', required: true },
|
|
257
|
+
{ name: 'barberId', type: 'string', required: true },
|
|
258
|
+
],
|
|
259
|
+
},
|
|
260
|
+
];
|
|
261
|
+
|
|
262
|
+
const result = extractMessagesFromSpecs(slice, allMessages);
|
|
263
|
+
|
|
264
|
+
expect(result.events).toEqual([
|
|
265
|
+
{
|
|
266
|
+
type: 'CustomerAppointments',
|
|
267
|
+
fields: [
|
|
268
|
+
{ name: 'customerId', tsType: 'string', required: true },
|
|
269
|
+
{ name: 'appointments', tsType: 'object[]', required: true },
|
|
270
|
+
],
|
|
271
|
+
source: 'then',
|
|
272
|
+
sourceSliceName: 'book barber appointment',
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
type: 'BarberAppointmentBooked',
|
|
276
|
+
fields: [
|
|
277
|
+
{ name: 'customerId', tsType: 'string', required: true },
|
|
278
|
+
{ name: 'barberId', tsType: 'string', required: true },
|
|
279
|
+
],
|
|
280
|
+
source: 'then',
|
|
281
|
+
sourceFlowName: undefined,
|
|
282
|
+
sourceSliceName: 'book barber appointment',
|
|
283
|
+
},
|
|
284
|
+
]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should still extract Given-step event refs with source given', () => {
|
|
288
|
+
const slice: Slice = {
|
|
289
|
+
type: 'command',
|
|
290
|
+
name: 'update order',
|
|
291
|
+
server: {
|
|
292
|
+
description: 'Updates an order',
|
|
293
|
+
specs: [
|
|
294
|
+
{
|
|
295
|
+
type: 'gherkin',
|
|
296
|
+
feature: 'Update order',
|
|
297
|
+
rules: [
|
|
298
|
+
{
|
|
299
|
+
name: 'Should update order',
|
|
300
|
+
examples: [
|
|
301
|
+
{
|
|
302
|
+
name: 'Order updated',
|
|
303
|
+
steps: [
|
|
304
|
+
{
|
|
305
|
+
keyword: 'Given',
|
|
306
|
+
text: 'OrderPlaced',
|
|
307
|
+
docString: { orderId: 'o1' },
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
keyword: 'When',
|
|
311
|
+
text: 'UpdateOrder',
|
|
312
|
+
docString: { orderId: 'o1', status: 'shipped' },
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
keyword: 'Then',
|
|
316
|
+
text: 'OrderUpdated',
|
|
317
|
+
docString: { orderId: 'o1', status: 'shipped' },
|
|
318
|
+
},
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const allMessages: MessageDefinition[] = [
|
|
330
|
+
{
|
|
331
|
+
type: 'event',
|
|
332
|
+
name: 'OrderPlaced',
|
|
333
|
+
fields: [{ name: 'orderId', type: 'string', required: true }],
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
type: 'command',
|
|
337
|
+
name: 'UpdateOrder',
|
|
338
|
+
fields: [
|
|
339
|
+
{ name: 'orderId', type: 'string', required: true },
|
|
340
|
+
{ name: 'status', type: 'string', required: true },
|
|
341
|
+
],
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
type: 'event',
|
|
345
|
+
name: 'OrderUpdated',
|
|
346
|
+
fields: [
|
|
347
|
+
{ name: 'orderId', type: 'string', required: true },
|
|
348
|
+
{ name: 'status', type: 'string', required: true },
|
|
349
|
+
],
|
|
350
|
+
},
|
|
351
|
+
];
|
|
352
|
+
|
|
353
|
+
const result = extractMessagesFromSpecs(slice, allMessages);
|
|
354
|
+
|
|
355
|
+
expect(result.events).toEqual([
|
|
356
|
+
{
|
|
357
|
+
type: 'OrderPlaced',
|
|
358
|
+
fields: [{ name: 'orderId', tsType: 'string', required: true }],
|
|
359
|
+
source: 'given',
|
|
360
|
+
sourceFlowName: undefined,
|
|
361
|
+
sourceSliceName: 'update order',
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
type: 'OrderUpdated',
|
|
365
|
+
fields: [
|
|
366
|
+
{ name: 'orderId', tsType: 'string', required: true },
|
|
367
|
+
{ name: 'status', tsType: 'string', required: true },
|
|
368
|
+
],
|
|
369
|
+
source: 'then',
|
|
370
|
+
sourceFlowName: undefined,
|
|
371
|
+
sourceSliceName: 'update order',
|
|
372
|
+
},
|
|
373
|
+
]);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe('extractMessagesFromSpecs (react slice with data target events)', () => {
|
|
378
|
+
it('should extract data target events with source then', () => {
|
|
379
|
+
const slice: Slice = {
|
|
380
|
+
type: 'react',
|
|
381
|
+
name: 'notify barber of new booking',
|
|
382
|
+
server: {
|
|
383
|
+
description: 'Notifies barber after booking',
|
|
384
|
+
data: {
|
|
385
|
+
items: [
|
|
386
|
+
{
|
|
387
|
+
target: { type: 'Event', name: 'BarberNotified' },
|
|
388
|
+
destination: { type: 'stream', pattern: 'barber-${barberId}' },
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
},
|
|
392
|
+
specs: [
|
|
393
|
+
{
|
|
394
|
+
type: 'gherkin',
|
|
395
|
+
feature: 'Notify barber reaction',
|
|
396
|
+
rules: [
|
|
397
|
+
{
|
|
398
|
+
name: 'Should notify barber',
|
|
399
|
+
examples: [
|
|
400
|
+
{
|
|
401
|
+
name: 'Barber notified',
|
|
402
|
+
steps: [
|
|
403
|
+
{
|
|
404
|
+
keyword: 'When',
|
|
405
|
+
text: 'AppointmentBooked',
|
|
406
|
+
docString: { appointmentId: 'a1', barberId: 'b1' },
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
keyword: 'Then',
|
|
410
|
+
text: 'NotifyBarber',
|
|
411
|
+
docString: { barberId: 'b1' },
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
},
|
|
415
|
+
],
|
|
416
|
+
},
|
|
417
|
+
],
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const allMessages: MessageDefinition[] = [
|
|
424
|
+
{
|
|
425
|
+
type: 'event',
|
|
426
|
+
name: 'AppointmentBooked',
|
|
427
|
+
fields: [
|
|
428
|
+
{ name: 'appointmentId', type: 'string', required: true },
|
|
429
|
+
{ name: 'barberId', type: 'string', required: true },
|
|
430
|
+
],
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
type: 'event',
|
|
434
|
+
name: 'BarberNotified',
|
|
435
|
+
fields: [
|
|
436
|
+
{ name: 'barberId', type: 'string', required: true },
|
|
437
|
+
{ name: 'notifiedAt', type: 'Date', required: true },
|
|
438
|
+
],
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
type: 'command',
|
|
442
|
+
name: 'NotifyBarber',
|
|
443
|
+
fields: [{ name: 'barberId', type: 'string', required: true }],
|
|
444
|
+
},
|
|
445
|
+
];
|
|
446
|
+
|
|
447
|
+
const result = extractMessagesFromSpecs(slice, allMessages);
|
|
448
|
+
|
|
449
|
+
expect(result.events).toEqual(
|
|
450
|
+
expect.arrayContaining([
|
|
451
|
+
{
|
|
452
|
+
type: 'BarberNotified',
|
|
453
|
+
fields: [
|
|
454
|
+
{ name: 'barberId', tsType: 'string', required: true },
|
|
455
|
+
{ name: 'notifiedAt', tsType: 'Date', required: true },
|
|
456
|
+
],
|
|
457
|
+
source: 'then',
|
|
458
|
+
sourceSliceName: 'notify barber of new booking',
|
|
459
|
+
},
|
|
460
|
+
]),
|
|
461
|
+
);
|
|
462
|
+
});
|
|
463
|
+
});
|
|
@@ -73,8 +73,23 @@ function extractMessagesForCommand(slice: Slice, allMessages: MessageDefinition[
|
|
|
73
73
|
debugCommand(' Extracted %d commands', commands.length);
|
|
74
74
|
debugCommand(' Command schemas: %o', Object.keys(commandSchemasByName));
|
|
75
75
|
|
|
76
|
+
const allGivenRefs = gwtSpecs.flatMap((gwt) => gwt.given);
|
|
77
|
+
const stateAsEvents: Message[] = allGivenRefs
|
|
78
|
+
.filter((ref) => !allMessages.some((m) => m.type === 'event' && m.name === ref.eventRef))
|
|
79
|
+
.filter((ref) => allMessages.some((m) => m.type === 'state' && m.name === ref.eventRef))
|
|
80
|
+
.map((ref) => ({
|
|
81
|
+
type: ref.eventRef,
|
|
82
|
+
fields: extractFieldsFromMessage(ref.eventRef, 'state', allMessages),
|
|
83
|
+
source: 'then' as const,
|
|
84
|
+
sourceSliceName: slice.name,
|
|
85
|
+
}));
|
|
86
|
+
debugCommand(' State-as-events from Given: %d', stateAsEvents.length);
|
|
87
|
+
|
|
76
88
|
const events: Message[] = gwtSpecs.flatMap((gwt): Message[] => {
|
|
77
|
-
const
|
|
89
|
+
const eventOnlyGiven = gwt.given.filter((ref) =>
|
|
90
|
+
allMessages.some((m) => m.type === 'event' && m.name === ref.eventRef),
|
|
91
|
+
);
|
|
92
|
+
const givenEvents = extractEventsFromGiven(eventOnlyGiven, allMessages, slice.name);
|
|
78
93
|
|
|
79
94
|
const thenEventsOnly = gwt.then.filter((item): item is EventRef => 'eventRef' in item);
|
|
80
95
|
const thenEvents = extractEventsFromThen(thenEventsOnly, allMessages, slice.name);
|
|
@@ -85,7 +100,7 @@ function extractMessagesForCommand(slice: Slice, allMessages: MessageDefinition[
|
|
|
85
100
|
|
|
86
101
|
const result = {
|
|
87
102
|
commands,
|
|
88
|
-
events: deduplicateMessages(events),
|
|
103
|
+
events: deduplicateMessages([...stateAsEvents, ...events]),
|
|
89
104
|
states: [],
|
|
90
105
|
commandSchemasByName,
|
|
91
106
|
};
|
|
@@ -198,9 +213,24 @@ function extractMessagesForReact(slice: Slice, allMessages: MessageDefinition[])
|
|
|
198
213
|
const dataStates = extractStatesFromData(slice, allMessages);
|
|
199
214
|
debugReact(' Extracted %d states from data', dataStates.length);
|
|
200
215
|
|
|
216
|
+
const dataTargetEvents: Message[] = [];
|
|
217
|
+
if (slice.server?.data?.items) {
|
|
218
|
+
for (const item of slice.server.data.items) {
|
|
219
|
+
if (item.target.type === 'Event') {
|
|
220
|
+
dataTargetEvents.push({
|
|
221
|
+
type: item.target.name,
|
|
222
|
+
fields: extractFieldsFromMessage(item.target.name, 'event', allMessages),
|
|
223
|
+
source: 'then' as const,
|
|
224
|
+
sourceSliceName: slice.name,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
debugReact(' Extracted %d data target events', dataTargetEvents.length);
|
|
230
|
+
|
|
201
231
|
const result = {
|
|
202
232
|
commands,
|
|
203
|
-
events: deduplicateMessages([...events, ...givenEvents]),
|
|
233
|
+
events: deduplicateMessages([...events, ...givenEvents, ...dataTargetEvents]),
|
|
204
234
|
states: deduplicateMessages([...dataStates, ...givenStates]),
|
|
205
235
|
commandSchemasByName,
|
|
206
236
|
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Narrative } from '@auto-engineer/narrative';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { findEventSource } from './scaffoldFromSchema';
|
|
4
|
+
|
|
5
|
+
describe('findEventSource', () => {
|
|
6
|
+
it('should find event in command slice GWT then steps', () => {
|
|
7
|
+
const flows: Narrative[] = [
|
|
8
|
+
{
|
|
9
|
+
name: 'order flow',
|
|
10
|
+
slices: [
|
|
11
|
+
{
|
|
12
|
+
type: 'command',
|
|
13
|
+
name: 'place order',
|
|
14
|
+
server: {
|
|
15
|
+
description: '',
|
|
16
|
+
specs: [
|
|
17
|
+
{
|
|
18
|
+
type: 'gherkin',
|
|
19
|
+
feature: 'Place order',
|
|
20
|
+
rules: [
|
|
21
|
+
{
|
|
22
|
+
name: 'Should place',
|
|
23
|
+
examples: [
|
|
24
|
+
{
|
|
25
|
+
name: 'Order placed',
|
|
26
|
+
steps: [
|
|
27
|
+
{ keyword: 'When', text: 'PlaceOrder', docString: {} },
|
|
28
|
+
{ keyword: 'Then', text: 'OrderPlaced', docString: {} },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
expect(findEventSource(flows, 'OrderPlaced')).toEqual({
|
|
43
|
+
flowName: 'order flow',
|
|
44
|
+
sliceName: 'place order',
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should find event produced by react data target', () => {
|
|
49
|
+
const flows: Narrative[] = [
|
|
50
|
+
{
|
|
51
|
+
name: 'booking flow',
|
|
52
|
+
slices: [
|
|
53
|
+
{
|
|
54
|
+
type: 'react',
|
|
55
|
+
name: 'notify barber of new booking',
|
|
56
|
+
server: {
|
|
57
|
+
description: 'Notifies barber after booking',
|
|
58
|
+
data: {
|
|
59
|
+
items: [
|
|
60
|
+
{
|
|
61
|
+
target: { type: 'Event', name: 'BarberNotified' },
|
|
62
|
+
destination: { type: 'stream', pattern: 'barber-${barberId}' },
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
specs: [
|
|
67
|
+
{
|
|
68
|
+
type: 'gherkin',
|
|
69
|
+
feature: 'Notify barber',
|
|
70
|
+
rules: [
|
|
71
|
+
{
|
|
72
|
+
name: 'Should notify',
|
|
73
|
+
examples: [
|
|
74
|
+
{
|
|
75
|
+
name: 'Barber notified',
|
|
76
|
+
steps: [
|
|
77
|
+
{ keyword: 'When', text: 'AppointmentBooked', docString: {} },
|
|
78
|
+
{ keyword: 'Then', text: 'NotifyBarber', docString: {} },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
expect(findEventSource(flows, 'BarberNotified')).toEqual({
|
|
93
|
+
flowName: 'booking flow',
|
|
94
|
+
sliceName: 'notify barber of new booking',
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should return null when event is not found anywhere', () => {
|
|
99
|
+
const flows: Narrative[] = [
|
|
100
|
+
{
|
|
101
|
+
name: 'empty flow',
|
|
102
|
+
slices: [
|
|
103
|
+
{
|
|
104
|
+
type: 'query',
|
|
105
|
+
name: 'some query',
|
|
106
|
+
server: {
|
|
107
|
+
description: '',
|
|
108
|
+
specs: [],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
expect(findEventSource(flows, 'NonExistentEvent')).toEqual(null);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -54,7 +54,7 @@ const defaultFilesByType: Record<string, string[]> = {
|
|
|
54
54
|
'register.ts.ejs',
|
|
55
55
|
],
|
|
56
56
|
query: ['projection.ts.ejs', 'state.ts.ejs', 'projection.specs.ts.ejs', 'query.resolver.ts.ejs'],
|
|
57
|
-
react: ['react.ts.ejs', 'react.specs.ts.ejs', 'register.ts.ejs'],
|
|
57
|
+
react: ['events.ts.ejs', 'react.ts.ejs', 'react.specs.ts.ejs', 'register.ts.ejs'],
|
|
58
58
|
};
|
|
59
59
|
|
|
60
60
|
interface EnumDefinition {
|
|
@@ -574,17 +574,25 @@ function canSliceProduceEvent(slice: Slice): boolean {
|
|
|
574
574
|
return ['command', 'react'].includes(slice.type) && 'server' in slice && Boolean(slice.server?.specs);
|
|
575
575
|
}
|
|
576
576
|
|
|
577
|
-
function findEventSource(flows: Narrative[], eventType: string): { flowName: string; sliceName: string } | null {
|
|
577
|
+
export function findEventSource(flows: Narrative[], eventType: string): { flowName: string; sliceName: string } | null {
|
|
578
578
|
debugSlice('Finding source for event: %s', eventType);
|
|
579
579
|
|
|
580
580
|
for (const flow of flows) {
|
|
581
581
|
for (const slice of flow.slices) {
|
|
582
|
-
if (
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
582
|
+
if (canSliceProduceEvent(slice)) {
|
|
583
|
+
const gwtSpecs = extractGwtSpecsFromSlice(slice);
|
|
584
|
+
if (hasEventInGwtSpecs(gwtSpecs, eventType)) {
|
|
585
|
+
debugSlice(' Found event source in flow: %s, slice: %s', flow.name, slice.name);
|
|
586
|
+
return { flowName: flow.name, sliceName: slice.name };
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (slice.type === 'react' && slice.server?.data?.items) {
|
|
590
|
+
for (const item of slice.server.data.items) {
|
|
591
|
+
if (item.target.type === 'Event' && item.target.name === eventType) {
|
|
592
|
+
debugSlice(' Found event source in react data target: flow=%s, slice=%s', flow.name, slice.name);
|
|
593
|
+
return { flowName: flow.name, sliceName: slice.name };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
588
596
|
}
|
|
589
597
|
}
|
|
590
598
|
}
|
|
@@ -1967,4 +1967,130 @@ describe('projection.specs.ts.ejs', () => {
|
|
|
1967
1967
|
// PaymentReceived lacks 'orderId' field, so should have a warning
|
|
1968
1968
|
expect(projectionFile?.contents).toContain("WARNING: These events lack field 'orderId': PaymentReceived");
|
|
1969
1969
|
});
|
|
1970
|
+
|
|
1971
|
+
it('should parse stringified JSON arrays in docString values for array-typed fields', async () => {
|
|
1972
|
+
const spec: SpecsSchema = {
|
|
1973
|
+
variant: 'specs',
|
|
1974
|
+
narratives: [
|
|
1975
|
+
{
|
|
1976
|
+
name: 'appointment-flow',
|
|
1977
|
+
slices: [
|
|
1978
|
+
{
|
|
1979
|
+
type: 'command',
|
|
1980
|
+
name: 'book-appointment',
|
|
1981
|
+
stream: 'appointments-${customerId}',
|
|
1982
|
+
client: { specs: [] },
|
|
1983
|
+
server: {
|
|
1984
|
+
description: '',
|
|
1985
|
+
specs: [
|
|
1986
|
+
{
|
|
1987
|
+
type: 'gherkin',
|
|
1988
|
+
feature: 'Book appointment',
|
|
1989
|
+
rules: [
|
|
1990
|
+
{
|
|
1991
|
+
name: 'Should book',
|
|
1992
|
+
examples: [
|
|
1993
|
+
{
|
|
1994
|
+
name: 'Appointment booked',
|
|
1995
|
+
steps: [
|
|
1996
|
+
{
|
|
1997
|
+
keyword: 'When',
|
|
1998
|
+
text: 'BookAppointment',
|
|
1999
|
+
docString: { customerId: 'c1' },
|
|
2000
|
+
},
|
|
2001
|
+
{
|
|
2002
|
+
keyword: 'Then',
|
|
2003
|
+
text: 'AppointmentBooked',
|
|
2004
|
+
docString: { customerId: 'c1' },
|
|
2005
|
+
},
|
|
2006
|
+
],
|
|
2007
|
+
},
|
|
2008
|
+
],
|
|
2009
|
+
},
|
|
2010
|
+
],
|
|
2011
|
+
},
|
|
2012
|
+
],
|
|
2013
|
+
},
|
|
2014
|
+
},
|
|
2015
|
+
{
|
|
2016
|
+
type: 'query',
|
|
2017
|
+
name: 'view-appointments',
|
|
2018
|
+
stream: 'appointments',
|
|
2019
|
+
client: { specs: [] },
|
|
2020
|
+
server: {
|
|
2021
|
+
description: '',
|
|
2022
|
+
data: {
|
|
2023
|
+
items: [
|
|
2024
|
+
{
|
|
2025
|
+
target: { type: 'State', name: 'CustomerAppointments' },
|
|
2026
|
+
origin: { type: 'projection', name: 'AppointmentsProjection', idField: 'customerId' },
|
|
2027
|
+
},
|
|
2028
|
+
],
|
|
2029
|
+
},
|
|
2030
|
+
specs: [
|
|
2031
|
+
{
|
|
2032
|
+
type: 'gherkin',
|
|
2033
|
+
feature: 'View appointments',
|
|
2034
|
+
rules: [
|
|
2035
|
+
{
|
|
2036
|
+
name: 'Should show appointments',
|
|
2037
|
+
examples: [
|
|
2038
|
+
{
|
|
2039
|
+
name: 'Shows booked appointment',
|
|
2040
|
+
steps: [
|
|
2041
|
+
{
|
|
2042
|
+
keyword: 'When',
|
|
2043
|
+
text: 'AppointmentBooked',
|
|
2044
|
+
docString: { customerId: 'c1' },
|
|
2045
|
+
},
|
|
2046
|
+
{
|
|
2047
|
+
keyword: 'Then',
|
|
2048
|
+
text: 'CustomerAppointments',
|
|
2049
|
+
docString: {
|
|
2050
|
+
customerId: 'c1',
|
|
2051
|
+
appointments: '[{"appointmentId":"appt_789","service":"haircut"}]',
|
|
2052
|
+
},
|
|
2053
|
+
},
|
|
2054
|
+
],
|
|
2055
|
+
},
|
|
2056
|
+
],
|
|
2057
|
+
},
|
|
2058
|
+
],
|
|
2059
|
+
},
|
|
2060
|
+
],
|
|
2061
|
+
},
|
|
2062
|
+
},
|
|
2063
|
+
],
|
|
2064
|
+
},
|
|
2065
|
+
],
|
|
2066
|
+
messages: [
|
|
2067
|
+
{
|
|
2068
|
+
type: 'command',
|
|
2069
|
+
name: 'BookAppointment',
|
|
2070
|
+
fields: [{ name: 'customerId', type: 'string', required: true }],
|
|
2071
|
+
},
|
|
2072
|
+
{
|
|
2073
|
+
type: 'event',
|
|
2074
|
+
name: 'AppointmentBooked',
|
|
2075
|
+
source: 'internal',
|
|
2076
|
+
fields: [{ name: 'customerId', type: 'string', required: true }],
|
|
2077
|
+
},
|
|
2078
|
+
{
|
|
2079
|
+
type: 'state',
|
|
2080
|
+
name: 'CustomerAppointments',
|
|
2081
|
+
fields: [
|
|
2082
|
+
{ name: 'customerId', type: 'string', required: true },
|
|
2083
|
+
{ name: 'appointments', type: '{ appointmentId: string; service: string }[]', required: true },
|
|
2084
|
+
],
|
|
2085
|
+
},
|
|
2086
|
+
],
|
|
2087
|
+
} as SpecsSchema;
|
|
2088
|
+
|
|
2089
|
+
const plans = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
|
|
2090
|
+
const specFile = plans.find((p) => p.outputPath.endsWith('view-appointments/projection.specs.ts'));
|
|
2091
|
+
|
|
2092
|
+
expect(specFile?.contents).toContain("appointmentId: 'appt_789'");
|
|
2093
|
+
expect(specFile?.contents).toContain("service: 'haircut'");
|
|
2094
|
+
expect(specFile?.contents).not.toContain('"[{\\"appointmentId\\"');
|
|
2095
|
+
});
|
|
1970
2096
|
});
|
|
@@ -59,6 +59,14 @@ function formatSpecValue(value, tsType) {
|
|
|
59
59
|
return `{ ${entries.join(', ')} }`;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
if (typeof value === 'string' && (tsType.includes('[]') || tsType.startsWith('Array<'))) {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(value);
|
|
65
|
+
if (Array.isArray(parsed)) {
|
|
66
|
+
return formatSpecValue(parsed, tsType);
|
|
67
|
+
}
|
|
68
|
+
} catch { /* not valid JSON — fall through to default */ }
|
|
69
|
+
}
|
|
62
70
|
return `'${value}'`;
|
|
63
71
|
}
|
|
64
72
|
|