@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.
Files changed (28) 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 +26 -0
  5. package/dist/src/codegen/extract/messages.d.ts.map +1 -1
  6. package/dist/src/codegen/extract/messages.js +29 -3
  7. package/dist/src/codegen/extract/messages.js.map +1 -1
  8. package/dist/src/codegen/scaffoldFromSchema.d.ts +4 -0
  9. package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
  10. package/dist/src/codegen/scaffoldFromSchema.js +16 -8
  11. package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
  12. package/dist/src/codegen/templates/query/projection.specs.specs.ts +126 -0
  13. package/dist/src/codegen/templates/query/projection.specs.ts.ejs +8 -0
  14. package/dist/src/codegen/templates/react/events.ts.ejs +17 -0
  15. package/dist/src/codegen/templates/react/react.ts.specs.ts +126 -0
  16. package/dist/src/codegen/templates/react/register.ts.ejs +3 -0
  17. package/dist/tsconfig.tsbuildinfo +1 -1
  18. package/ketchup-plan.md +6 -7
  19. package/package.json +4 -4
  20. package/src/codegen/extract/messages.specs.ts +273 -0
  21. package/src/codegen/extract/messages.ts +33 -3
  22. package/src/codegen/findEventSource.specs.ts +117 -0
  23. package/src/codegen/scaffoldFromSchema.ts +16 -8
  24. package/src/codegen/templates/query/projection.specs.specs.ts +126 -0
  25. package/src/codegen/templates/query/projection.specs.ts.ejs +8 -0
  26. package/src/codegen/templates/react/events.ts.ejs +17 -0
  27. package/src/codegen/templates/react/react.ts.specs.ts +126 -0
  28. package/src/codegen/templates/react/register.ts.ejs +3 -0
package/ketchup-plan.md CHANGED
@@ -1,12 +1,11 @@
1
- # Ketchup Plan: Replace fs.remove(serverDir) with selective cleanServerDir
1
+ # Ketchup Plan: Fix Event Resolution Bugs
2
2
 
3
3
  ## TODO
4
4
 
5
5
  ## DONE
6
6
 
7
- - [x] Burst 0: Revert partial edit to restore green baseline (already green)
8
- - [x] Burst 1: cleanServerDir removes src/ when present (05a4870e)
9
- - [x] Burst 2: cleanServerDir removes scripts/ and dist/ when present (112adfb4)
10
- - [x] Burst 3: cleanServerDir preserves node_modules/ (d97a199c)
11
- - [x] Burst 4: cleanServerDir creates serverDir when missing (855c063c)
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.3",
36
- "@auto-engineer/message-bus": "1.36.3"
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.36.3"
47
+ "@auto-engineer/cli": "1.37.0"
48
48
  },
49
- "version": "1.36.3",
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 givenEvents = extractEventsFromGiven(gwt.given, allMessages, slice.name);
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 (!canSliceProduceEvent(slice)) continue;
583
-
584
- const gwtSpecs = extractGwtSpecsFromSlice(slice);
585
- if (hasEventInGwtSpecs(gwtSpecs, eventType)) {
586
- debugSlice(' Found event source in flow: %s, slice: %s', flow.name, slice.name);
587
- return { flowName: flow.name, sliceName: slice.name };
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