@auto-engineer/server-generator-apollo-emmett 1.104.0 → 1.105.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 +66 -0
- package/dist/src/codegen/scaffoldFromSchema.d.ts +10 -0
- package/dist/src/codegen/scaffoldFromSchema.d.ts.map +1 -1
- package/dist/src/codegen/scaffoldFromSchema.js +55 -20
- package/dist/src/codegen/scaffoldFromSchema.js.map +1 -1
- package/dist/src/codegen/templates/command/decide.specs.ts +16 -4
- package/dist/src/codegen/templates/command/decide.ts.ejs +4 -1
- package/dist/src/codegen/templates/command/evolve.specs.ts +16 -15
- package/dist/src/codegen/templates/command/evolve.ts.ejs +16 -15
- package/dist/src/codegen/templates/command/handle.specs.ts +146 -0
- package/dist/src/codegen/templates/command/handle.ts.ejs +21 -1
- package/dist/src/codegen/templates/query/projection.specs.ts +4 -1
- package/dist/src/codegen/templates/query/projection.ts.ejs +7 -1
- package/dist/src/codegen/templates/react/react.specs.specs.ts +0 -32
- package/dist/src/codegen/templates/react/react.ts.specs.ts +0 -49
- package/dist/src/commands/generate-server.d.ts +4 -0
- package/dist/src/commands/generate-server.d.ts.map +1 -1
- package/dist/src/commands/generate-server.js +37 -3
- package/dist/src/commands/generate-server.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/ketchup-plan.md +2 -0
- package/package.json +4 -4
- package/src/codegen/formatTsValue.specs.ts +12 -0
- package/src/codegen/formatTsValueSimple.specs.ts +1 -1
- package/src/codegen/scaffoldErrors.specs.ts +40 -0
- package/src/codegen/scaffoldFromSchema.ts +70 -31
- package/src/codegen/templates/command/decide.specs.ts +16 -4
- package/src/codegen/templates/command/decide.ts.ejs +4 -1
- package/src/codegen/templates/command/evolve.specs.ts +16 -15
- package/src/codegen/templates/command/evolve.ts.ejs +16 -15
- package/src/codegen/templates/command/handle.specs.ts +146 -0
- package/src/codegen/templates/command/handle.ts.ejs +21 -1
- package/src/codegen/templates/query/projection.specs.ts +4 -1
- package/src/codegen/templates/query/projection.ts.ejs +7 -1
- package/src/codegen/templates/react/react.specs.specs.ts +0 -32
- package/src/codegen/templates/react/react.ts.specs.ts +0 -49
- package/src/commands/generate-server.specs.ts +71 -0
- package/src/commands/generate-server.ts +45 -2
package/ketchup-plan.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
## DONE
|
|
8
8
|
|
|
9
|
+
- [x] Burst 24: Fix handle.ts multi-command stream pattern
|
|
10
|
+
- [x] Burst 23: Fix decide default case + add discriminated union narrowing guidance (a24a654f)
|
|
9
11
|
- [x] Burst 22: Add null-document guidance to projection template (0d1d2007)
|
|
10
12
|
- [x] Burst 21: Add discriminated union guidance to evolve template (7abc038c)
|
|
11
13
|
- [x] Burst 20: Fill missing inline object fields with type defaults (b39bfd1b)
|
package/package.json
CHANGED
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"uuid": "^13.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.105.0",
|
|
36
|
+
"@auto-engineer/message-bus": "1.105.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.105.0"
|
|
48
48
|
},
|
|
49
|
-
"version": "1.
|
|
49
|
+
"version": "1.105.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",
|
|
@@ -22,6 +22,18 @@ describe('formatTsValue', () => {
|
|
|
22
22
|
it('returns null for null value', () => {
|
|
23
23
|
expect(formatTsValue(null, 'string')).toBe('null');
|
|
24
24
|
});
|
|
25
|
+
|
|
26
|
+
it('returns 0 for non-numeric string with number type', () => {
|
|
27
|
+
expect(formatTsValue('null', 'number')).toBe('0');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns 0 for empty string with number type', () => {
|
|
31
|
+
expect(formatTsValue('', 'number')).toBe('0');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns 0 for boolean with number type', () => {
|
|
35
|
+
expect(formatTsValue(true, 'number')).toBe('0');
|
|
36
|
+
});
|
|
25
37
|
});
|
|
26
38
|
|
|
27
39
|
describe('simple arrays', () => {
|
|
@@ -27,7 +27,7 @@ describe('formatTsValueSimple', () => {
|
|
|
27
27
|
});
|
|
28
28
|
|
|
29
29
|
it('should return number string for string value when tsType is number', () => {
|
|
30
|
-
expect(formatTsValueSimple('5.00', 'number')).toBe('5
|
|
30
|
+
expect(formatTsValueSimple('5.00', 'number')).toBe('5');
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
it('should return number string for number value when tsType is number', () => {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ScaffoldError, TemplateRenderError } from './scaffoldFromSchema';
|
|
3
|
+
|
|
4
|
+
describe('TemplateRenderError', () => {
|
|
5
|
+
it('captures template file and chains cause', () => {
|
|
6
|
+
const original = new TypeError('Cannot read properties of undefined');
|
|
7
|
+
const error = new TemplateRenderError('decide.ts.ejs', original);
|
|
8
|
+
|
|
9
|
+
expect(error).toEqual(
|
|
10
|
+
expect.objectContaining({
|
|
11
|
+
name: 'TemplateRenderError',
|
|
12
|
+
message: 'Template render failed: decide.ts.ejs',
|
|
13
|
+
templateFile: 'decide.ts.ejs',
|
|
14
|
+
cause: original,
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
expect(error).toBeInstanceOf(Error);
|
|
18
|
+
expect(error).toBeInstanceOf(TemplateRenderError);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('ScaffoldError', () => {
|
|
23
|
+
it('captures flow/slice/type and chains cause', () => {
|
|
24
|
+
const templateError = new TemplateRenderError('evolve.ts.ejs', new Error('bad data'));
|
|
25
|
+
const error = new ScaffoldError('Checkout', 'PlaceOrder', 'command', templateError);
|
|
26
|
+
|
|
27
|
+
expect(error).toEqual(
|
|
28
|
+
expect.objectContaining({
|
|
29
|
+
name: 'ScaffoldError',
|
|
30
|
+
message: 'Scaffold failed for Checkout/PlaceOrder (command)',
|
|
31
|
+
flowName: 'Checkout',
|
|
32
|
+
sliceName: 'PlaceOrder',
|
|
33
|
+
sliceType: 'command',
|
|
34
|
+
cause: templateError,
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
expect(error).toBeInstanceOf(Error);
|
|
38
|
+
expect(error).toBeInstanceOf(ScaffoldError);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -47,6 +47,30 @@ import { normalizeSliceForTemplate } from './extract/slice-normalizer';
|
|
|
47
47
|
import { extractGwtSpecsFromSlice, type GwtResult } from './extract/step-converter';
|
|
48
48
|
import type { GwtCondition, Message, MessageDefinition } from './types';
|
|
49
49
|
|
|
50
|
+
export class TemplateRenderError extends Error {
|
|
51
|
+
readonly templateFile: string;
|
|
52
|
+
|
|
53
|
+
constructor(templateFile: string, cause: unknown) {
|
|
54
|
+
super(`Template render failed: ${templateFile}`, { cause });
|
|
55
|
+
this.name = 'TemplateRenderError';
|
|
56
|
+
this.templateFile = templateFile;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class ScaffoldError extends Error {
|
|
61
|
+
readonly flowName: string;
|
|
62
|
+
readonly sliceName: string;
|
|
63
|
+
readonly sliceType: string;
|
|
64
|
+
|
|
65
|
+
constructor(flowName: string, sliceName: string, sliceType: string, cause: unknown) {
|
|
66
|
+
super(`Scaffold failed for ${flowName}/${sliceName} (${sliceType})`, { cause });
|
|
67
|
+
this.name = 'ScaffoldError';
|
|
68
|
+
this.flowName = flowName;
|
|
69
|
+
this.sliceName = sliceName;
|
|
70
|
+
this.sliceType = sliceType;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
50
74
|
const defaultFilesByType: Record<string, string[]> = {
|
|
51
75
|
command: [
|
|
52
76
|
'commands.ts.ejs',
|
|
@@ -419,7 +443,14 @@ export function formatTsValueSimple(value: unknown, tsType: string): string {
|
|
|
419
443
|
return JSON.stringify(typeof value === 'string' ? value : String(value));
|
|
420
444
|
}
|
|
421
445
|
if (tsType === 'number') {
|
|
422
|
-
return String(value);
|
|
446
|
+
if (typeof value === 'number') return String(value);
|
|
447
|
+
if (typeof value === 'string') {
|
|
448
|
+
const s = value.trim();
|
|
449
|
+
if (s === '') return '0';
|
|
450
|
+
const num = Number(s);
|
|
451
|
+
return Number.isNaN(num) ? '0' : String(num);
|
|
452
|
+
}
|
|
453
|
+
return '0';
|
|
423
454
|
}
|
|
424
455
|
if (tsType === 'boolean') {
|
|
425
456
|
return value === true || value === 'true' ? 'true' : 'false';
|
|
@@ -520,23 +551,27 @@ async function generateFileForTemplate(
|
|
|
520
551
|
debugFiles(' Template path: %s', templatePath);
|
|
521
552
|
debugFiles(' Output path: %s', outputPath);
|
|
522
553
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
554
|
+
try {
|
|
555
|
+
const contents = await renderTemplate(templatePath, templateData, unionToEnumName);
|
|
556
|
+
debugFiles(' Rendered content size: %d bytes', contents.length);
|
|
557
|
+
|
|
558
|
+
debugFiles(' Formatting with Prettier...');
|
|
559
|
+
const formattedContents = await prettier.format(contents, {
|
|
560
|
+
parser: 'typescript',
|
|
561
|
+
filepath: outputPath,
|
|
562
|
+
singleQuote: true,
|
|
563
|
+
trailingComma: 'all',
|
|
564
|
+
printWidth: 120,
|
|
565
|
+
tabWidth: 2,
|
|
566
|
+
});
|
|
567
|
+
debugFiles(' Formatted content size: %d bytes', formattedContents.length);
|
|
568
|
+
|
|
569
|
+
const plan = { outputPath, contents: formattedContents };
|
|
570
|
+
debugFiles(' File plan created for: %s', fileName);
|
|
571
|
+
return plan;
|
|
572
|
+
} catch (error) {
|
|
573
|
+
throw new TemplateRenderError(templateFile, error);
|
|
574
|
+
}
|
|
540
575
|
}
|
|
541
576
|
|
|
542
577
|
export function resolveReferencedMessageTypes(
|
|
@@ -918,19 +953,23 @@ export async function generateScaffoldFilePlans(
|
|
|
918
953
|
debugFlow(' Processing slice: %s (type: %s)', slice.name, slice.type);
|
|
919
954
|
const sliceDir = ensureDirPath(flowDir, toKebabCase(slice.name));
|
|
920
955
|
debugFlow(' Slice directory: %s', sliceDir);
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
956
|
+
try {
|
|
957
|
+
const { plans, duplicateCommands } = await generateFilesForSlice(
|
|
958
|
+
slice,
|
|
959
|
+
flow,
|
|
960
|
+
sliceDir,
|
|
961
|
+
sanitizedMessages,
|
|
962
|
+
flows,
|
|
963
|
+
unionToEnumName,
|
|
964
|
+
integrations,
|
|
965
|
+
registeredCommands,
|
|
966
|
+
);
|
|
967
|
+
debugFlow(' Generated %d plans for slice', plans.length);
|
|
968
|
+
allPlans.push(...plans);
|
|
969
|
+
allDuplicateCommands.push(...duplicateCommands);
|
|
970
|
+
} catch (error) {
|
|
971
|
+
throw new ScaffoldError(flow.name, slice.name, slice.type, error);
|
|
972
|
+
}
|
|
934
973
|
}
|
|
935
974
|
debugFlow(' Completed flow: %s', flow.name);
|
|
936
975
|
}
|
|
@@ -97,6 +97,9 @@ describe('decide.ts.ejs', () => {
|
|
|
97
97
|
* You should:
|
|
98
98
|
* - Validate the command input fields
|
|
99
99
|
* - Inspect the current domain \`_state\` to determine if the command is allowed
|
|
100
|
+
* - If State is a discriminated union, NEVER use \`as any\` to bypass type checking.
|
|
101
|
+
* Narrow with the discriminant: \`if (_state.status !== 'active') throw new IllegalStateError('...');\`
|
|
102
|
+
* After narrowing, access variant fields directly — TypeScript infers the correct type.
|
|
100
103
|
* - If invalid, throw one of the following domain errors: \`IllegalStateError\`
|
|
101
104
|
* ⚠️ Error constructors: IllegalStateError takes a string message
|
|
102
105
|
* - If valid, return one or more events with the correct structure
|
|
@@ -117,7 +120,7 @@ describe('decide.ts.ejs', () => {
|
|
|
117
120
|
throw new IllegalStateError('Not yet implemented: ' + command.type);
|
|
118
121
|
}
|
|
119
122
|
default:
|
|
120
|
-
throw new IllegalStateError(
|
|
123
|
+
throw new IllegalStateError('Unexpected command type');
|
|
121
124
|
}
|
|
122
125
|
};
|
|
123
126
|
"
|
|
@@ -232,6 +235,9 @@ describe('decide.ts.ejs', () => {
|
|
|
232
235
|
* You should:
|
|
233
236
|
* - Validate the command input fields
|
|
234
237
|
* - Inspect the current domain \`_state\` to determine if the command is allowed
|
|
238
|
+
* - If State is a discriminated union, NEVER use \`as any\` to bypass type checking.
|
|
239
|
+
* Narrow with the discriminant: \`if (_state.status !== 'active') throw new IllegalStateError('...');\`
|
|
240
|
+
* After narrowing, access variant fields directly — TypeScript infers the correct type.
|
|
235
241
|
* - If invalid, throw one of the following domain errors: \`IllegalStateError\`
|
|
236
242
|
* ⚠️ Error constructors: IllegalStateError takes a string message
|
|
237
243
|
* - If valid, return one or more events with the correct structure
|
|
@@ -258,7 +264,7 @@ describe('decide.ts.ejs', () => {
|
|
|
258
264
|
throw new IllegalStateError('Not yet implemented: ' + command.type);
|
|
259
265
|
}
|
|
260
266
|
default:
|
|
261
|
-
throw new IllegalStateError(
|
|
267
|
+
throw new IllegalStateError('Unexpected command type');
|
|
262
268
|
}
|
|
263
269
|
};
|
|
264
270
|
"
|
|
@@ -393,6 +399,9 @@ describe('decide.ts.ejs', () => {
|
|
|
393
399
|
* You should:
|
|
394
400
|
* - Validate the command input fields
|
|
395
401
|
* - Inspect the current domain \`_state\` to determine if the command is allowed
|
|
402
|
+
* - If State is a discriminated union, NEVER use \`as any\` to bypass type checking.
|
|
403
|
+
* Narrow with the discriminant: \`if (_state.status !== 'active') throw new IllegalStateError('...');\`
|
|
404
|
+
* After narrowing, access variant fields directly — TypeScript infers the correct type.
|
|
396
405
|
* - If invalid, throw one of the following domain errors: \`IllegalStateError\`, \`ValidationError\`
|
|
397
406
|
* ⚠️ Error constructors: IllegalStateError takes a string message, ValidationError takes a string message
|
|
398
407
|
* - If valid, return one or more events with the correct structure
|
|
@@ -417,7 +426,7 @@ describe('decide.ts.ejs', () => {
|
|
|
417
426
|
throw new IllegalStateError('Not yet implemented: ' + command.type);
|
|
418
427
|
}
|
|
419
428
|
default:
|
|
420
|
-
throw new IllegalStateError(
|
|
429
|
+
throw new IllegalStateError('Unexpected command type');
|
|
421
430
|
}
|
|
422
431
|
};
|
|
423
432
|
"
|
|
@@ -592,6 +601,9 @@ describe('decide.ts.ejs', () => {
|
|
|
592
601
|
* You should:
|
|
593
602
|
* - Validate the command input fields
|
|
594
603
|
* - Inspect the current domain \`_state\` to determine if the command is allowed
|
|
604
|
+
* - If State is a discriminated union, NEVER use \`as any\` to bypass type checking.
|
|
605
|
+
* Narrow with the discriminant: \`if (_state.status !== 'active') throw new IllegalStateError('...');\`
|
|
606
|
+
* After narrowing, access variant fields directly — TypeScript infers the correct type.
|
|
595
607
|
* - Use \`products\` (integration result) to enrich or filter the output
|
|
596
608
|
* - If invalid, throw one of the following domain errors: \`IllegalStateError\`
|
|
597
609
|
* ⚠️ Error constructors: IllegalStateError takes a string message
|
|
@@ -627,7 +639,7 @@ describe('decide.ts.ejs', () => {
|
|
|
627
639
|
throw new IllegalStateError('Not yet implemented: ' + command.type);
|
|
628
640
|
}
|
|
629
641
|
default:
|
|
630
|
-
throw new IllegalStateError(
|
|
642
|
+
throw new IllegalStateError('Unexpected command type');
|
|
631
643
|
}
|
|
632
644
|
};
|
|
633
645
|
"
|
|
@@ -61,6 +61,9 @@ case '<%= command %>': {
|
|
|
61
61
|
* You should:
|
|
62
62
|
* - Validate the command input fields
|
|
63
63
|
* - Inspect the current domain `_state` to determine if the command is allowed
|
|
64
|
+
* - If State is a discriminated union, NEVER use `as any` to bypass type checking.
|
|
65
|
+
* Narrow with the discriminant: `if (_state.status !== 'active') throw new IllegalStateError('...');`
|
|
66
|
+
* After narrowing, access variant fields directly — TypeScript infers the correct type.
|
|
64
67
|
<% if (integrationReturnType) { -%>
|
|
65
68
|
* - Use `<%= camelCase(integrationReturnType) %>` (integration result) to enrich or filter the output
|
|
66
69
|
<% } -%>
|
|
@@ -132,6 +135,6 @@ throw new IllegalStateError('Not yet implemented: ' + command.type);
|
|
|
132
135
|
}
|
|
133
136
|
<% } -%>
|
|
134
137
|
default:
|
|
135
|
-
throw new IllegalStateError(
|
|
138
|
+
throw new IllegalStateError('Unexpected command type');
|
|
136
139
|
}
|
|
137
140
|
};
|
|
@@ -87,27 +87,28 @@ describe('evolve.ts.ejs', () => {
|
|
|
87
87
|
/**
|
|
88
88
|
* ## IMPLEMENTATION INSTRUCTIONS ##
|
|
89
89
|
*
|
|
90
|
-
*
|
|
90
|
+
* Evolve domain state in response to events.
|
|
91
|
+
* Only track fields needed for future decisions in decide.ts.
|
|
91
92
|
*
|
|
92
|
-
*
|
|
93
|
-
* -
|
|
94
|
-
* -
|
|
95
|
-
* - If
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* -
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* \`return
|
|
93
|
+
* RULES:
|
|
94
|
+
* - Return a new object literal for state transitions.
|
|
95
|
+
* For no-op (event doesn’t affect state), return the existing \`state\`.
|
|
96
|
+
* - If State is a discriminated union (check state.ts), EVERY return
|
|
97
|
+
* MUST include the discriminant field as a string literal.
|
|
98
|
+
* Example: \`return { status: ‘active’, field: event.data.field };\`
|
|
99
|
+
* - NEVER spread ...event.data or ...state — list each field explicitly.
|
|
100
|
+
*
|
|
101
|
+
* VERIFY before finalizing:
|
|
102
|
+
* [ ] Every return (except \`return state;\`) matches a variant from state.ts
|
|
103
|
+
* [ ] Every return includes the discriminant field
|
|
104
|
+
* [ ] No spread operators in return statements
|
|
102
105
|
*/
|
|
103
106
|
|
|
104
107
|
export const evolve = (state: State, event: ListingCreated): State => {
|
|
105
108
|
switch (event.type) {
|
|
106
109
|
case 'ListingCreated': {
|
|
107
|
-
// TODO:
|
|
108
|
-
return
|
|
109
|
-
...state,
|
|
110
|
-
};
|
|
110
|
+
// TODO: Return { status: 'variant', field1: ..., field2: ... } matching state.ts.
|
|
111
|
+
return state;
|
|
111
112
|
}
|
|
112
113
|
default:
|
|
113
114
|
return state;
|
|
@@ -7,18 +7,21 @@
|
|
|
7
7
|
/**
|
|
8
8
|
* ## IMPLEMENTATION INSTRUCTIONS ##
|
|
9
9
|
*
|
|
10
|
-
*
|
|
10
|
+
* Evolve domain state in response to events.
|
|
11
|
+
* Only track fields needed for future decisions in decide.ts.
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
* -
|
|
15
|
-
* - If
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* -
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* `return
|
|
13
|
+
* RULES:
|
|
14
|
+
* - Return a new object literal for state transitions.
|
|
15
|
+
* For no-op (event doesn’t affect state), return the existing `state`.
|
|
16
|
+
* - If State is a discriminated union (check state.ts), EVERY return
|
|
17
|
+
* MUST include the discriminant field as a string literal.
|
|
18
|
+
* Example: `return { status: ‘active’, field: event.data.field };`
|
|
19
|
+
* - NEVER spread ...event.data or ...state — list each field explicitly.
|
|
20
|
+
*
|
|
21
|
+
* VERIFY before finalizing:
|
|
22
|
+
* [ ] Every return (except `return state;`) matches a variant from state.ts
|
|
23
|
+
* [ ] Every return includes the discriminant field
|
|
24
|
+
* [ ] No spread operators in return statements
|
|
22
25
|
*/
|
|
23
26
|
|
|
24
27
|
export const evolve = (
|
|
@@ -28,10 +31,8 @@
|
|
|
28
31
|
switch (event.type) {
|
|
29
32
|
<% events.forEach(event => { -%>
|
|
30
33
|
case '<%= event.type %>': {
|
|
31
|
-
// TODO:
|
|
32
|
-
return
|
|
33
|
-
...state
|
|
34
|
-
};
|
|
34
|
+
// TODO: Return { status: 'variant', field1: ..., field2: ... } matching state.ts.
|
|
35
|
+
return state;
|
|
35
36
|
}
|
|
36
37
|
<% }); -%>
|
|
37
38
|
default:
|
|
@@ -331,4 +331,150 @@ describe('generateScaffoldFilePlans', () => {
|
|
|
331
331
|
"
|
|
332
332
|
`);
|
|
333
333
|
});
|
|
334
|
+
it('should generate stream guard when not all commands share stream pattern fields', async () => {
|
|
335
|
+
const spec: SpecsSchema = {
|
|
336
|
+
variant: 'specs',
|
|
337
|
+
narratives: [
|
|
338
|
+
{
|
|
339
|
+
name: 'Member tracks workouts',
|
|
340
|
+
slices: [
|
|
341
|
+
{
|
|
342
|
+
type: 'command',
|
|
343
|
+
name: 'Track workouts',
|
|
344
|
+
stream: 'workouts-${memberId}',
|
|
345
|
+
client: {
|
|
346
|
+
specs: [],
|
|
347
|
+
},
|
|
348
|
+
server: {
|
|
349
|
+
description: 'test',
|
|
350
|
+
specs: [
|
|
351
|
+
{
|
|
352
|
+
type: 'gherkin',
|
|
353
|
+
feature: 'Track workouts',
|
|
354
|
+
rules: [
|
|
355
|
+
{
|
|
356
|
+
name: 'Should log workout',
|
|
357
|
+
examples: [
|
|
358
|
+
{
|
|
359
|
+
name: 'Member logs a workout',
|
|
360
|
+
steps: [
|
|
361
|
+
{
|
|
362
|
+
keyword: 'When',
|
|
363
|
+
text: 'LogWorkout',
|
|
364
|
+
docString: { memberId: 'm1', exercise: 'squat' },
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
keyword: 'Then',
|
|
368
|
+
text: 'WorkoutLogged',
|
|
369
|
+
docString: { memberId: 'm1', exercise: 'squat' },
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
},
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: 'Should calculate points',
|
|
377
|
+
examples: [
|
|
378
|
+
{
|
|
379
|
+
name: 'Points are calculated',
|
|
380
|
+
steps: [
|
|
381
|
+
{
|
|
382
|
+
keyword: 'When',
|
|
383
|
+
text: 'CalculatePoints',
|
|
384
|
+
docString: { season: 'winter' },
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
keyword: 'Then',
|
|
388
|
+
text: 'PointsCalculated',
|
|
389
|
+
docString: { season: 'winter', points: 10 },
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
data: {
|
|
399
|
+
items: [
|
|
400
|
+
{
|
|
401
|
+
target: { type: 'Event', name: 'WorkoutLogged' },
|
|
402
|
+
destination: {
|
|
403
|
+
type: 'stream',
|
|
404
|
+
pattern: 'workouts-${memberId}',
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
],
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
],
|
|
412
|
+
},
|
|
413
|
+
],
|
|
414
|
+
messages: [
|
|
415
|
+
{
|
|
416
|
+
type: 'command',
|
|
417
|
+
name: 'LogWorkout',
|
|
418
|
+
fields: [
|
|
419
|
+
{ name: 'memberId', type: 'string', required: true },
|
|
420
|
+
{ name: 'exercise', type: 'string', required: true },
|
|
421
|
+
],
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
type: 'command',
|
|
425
|
+
name: 'CalculatePoints',
|
|
426
|
+
fields: [{ name: 'season', type: 'string', required: true }],
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
type: 'event',
|
|
430
|
+
name: 'WorkoutLogged',
|
|
431
|
+
source: 'internal',
|
|
432
|
+
fields: [
|
|
433
|
+
{ name: 'memberId', type: 'string', required: true },
|
|
434
|
+
{ name: 'exercise', type: 'string', required: true },
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
type: 'event',
|
|
439
|
+
name: 'PointsCalculated',
|
|
440
|
+
source: 'internal',
|
|
441
|
+
fields: [
|
|
442
|
+
{ name: 'season', type: 'string', required: true },
|
|
443
|
+
{ name: 'points', type: 'number', required: true },
|
|
444
|
+
],
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const { plans } = await generateScaffoldFilePlans(spec.narratives, spec.messages, undefined, 'src/domain/flows');
|
|
450
|
+
const handleFile = plans.find((p) => p.outputPath.endsWith('handle.ts'));
|
|
451
|
+
|
|
452
|
+
expect(handleFile?.contents).toMatchInlineSnapshot(`
|
|
453
|
+
"import { CommandHandler, type EventStore, type MessageHandlerResult } from '@event-driven-io/emmett';
|
|
454
|
+
import { evolve } from './evolve';
|
|
455
|
+
import { initialState } from './state';
|
|
456
|
+
import { decide } from './decide';
|
|
457
|
+
import type { LogWorkout, CalculatePoints } from './commands';
|
|
458
|
+
|
|
459
|
+
const handler = CommandHandler({
|
|
460
|
+
evolve,
|
|
461
|
+
initialState,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
export const handle = async (
|
|
465
|
+
eventStore: EventStore,
|
|
466
|
+
command: LogWorkout | CalculatePoints,
|
|
467
|
+
): Promise<MessageHandlerResult> => {
|
|
468
|
+
const commandData = command.data;
|
|
469
|
+
if (!('memberId' in commandData)) {
|
|
470
|
+
throw new Error('Cannot determine stream: "memberId" not in command data');
|
|
471
|
+
}
|
|
472
|
+
const streamId = \`workouts-\${commandData.memberId}\`;
|
|
473
|
+
|
|
474
|
+
await handler(eventStore, streamId, (state) => decide(command, state));
|
|
475
|
+
return undefined;
|
|
476
|
+
};
|
|
477
|
+
"
|
|
478
|
+
`);
|
|
479
|
+
});
|
|
334
480
|
});
|
|
@@ -53,6 +53,16 @@ const integrationCalls = integrationData.map((d, i) => {
|
|
|
53
53
|
|
|
54
54
|
const resultVarName = integrationCalls.find(call => !!call.varName)?.varName;
|
|
55
55
|
const needsReturnValue = typeof resultVarName === 'string';
|
|
56
|
+
|
|
57
|
+
const streamVars = [];
|
|
58
|
+
const streamPatternStr = stream?.pattern ?? '';
|
|
59
|
+
const svRegex = /\$\{([^}]+)\}/g;
|
|
60
|
+
let svMatch;
|
|
61
|
+
while ((svMatch = svRegex.exec(streamPatternStr)) !== null) {
|
|
62
|
+
streamVars.push(svMatch[1]);
|
|
63
|
+
}
|
|
64
|
+
const allCmdFieldSets = commands.map(c => new Set((c.fields ?? []).map(f => f.name)));
|
|
65
|
+
const needsStreamGuard = streamVars.length > 0 && !allCmdFieldSets.every(fs => streamVars.every(v => fs.has(v)));
|
|
56
66
|
%>
|
|
57
67
|
|
|
58
68
|
<% integrationSideEffectImports.forEach((importSource) => { %>
|
|
@@ -86,7 +96,17 @@ eventStore: EventStore,
|
|
|
86
96
|
command: <%= commands.map(c => pascalCase(c.type)).join(' | ') %>
|
|
87
97
|
): Promise<MessageHandlerResult> => {
|
|
88
98
|
<% if (stream?.pattern?.includes('${')) { -%>
|
|
89
|
-
|
|
99
|
+
<% if (needsStreamGuard) { -%>
|
|
100
|
+
const commandData = command.data;
|
|
101
|
+
<% for (const v of streamVars) { -%>
|
|
102
|
+
if (!('<%= v %>' in commandData)) {
|
|
103
|
+
throw new Error('Cannot determine stream: "<%= v %>" not in command data');
|
|
104
|
+
}
|
|
105
|
+
<% } -%>
|
|
106
|
+
const streamId = `<%= stream.pattern.replace(/\$\{([^}]+)\}/g, (_, key) => `\${commandData.${key}}`) %>`;
|
|
107
|
+
<% } else { -%>
|
|
108
|
+
const streamId = `<%= stream.pattern.replace(/\$\{([^}]+)\}/g, (_, key) => `\${command.data.${key}}`) %>`;
|
|
109
|
+
<% } -%>
|
|
90
110
|
<% } else { -%>
|
|
91
111
|
const streamId = '<%= stream?.pattern ?? 'unknown-stream' %>';
|
|
92
112
|
<% } -%>
|
|
@@ -562,8 +562,11 @@ describe('projection.ts.ejs', () => {
|
|
|
562
562
|
// SINGLETON AGGREGATION PATTERN
|
|
563
563
|
// This projection maintains a single document that aggregates data from multiple entities.
|
|
564
564
|
// Use internal state to track individual entity information for accurate calculations.
|
|
565
|
+
|
|
566
|
+
type EntityData = TodoAdded['data'];
|
|
567
|
+
|
|
565
568
|
interface InternalTodoSummary extends TodoSummary {
|
|
566
|
-
_entities?: Record<string,
|
|
569
|
+
_entities?: Record<string, EntityData>;
|
|
567
570
|
}
|
|
568
571
|
|
|
569
572
|
type AllEvents = TodoAdded;
|
|
@@ -45,8 +45,14 @@ import { <%= stateEnums.join(', ') %> } from '../../../shared';
|
|
|
45
45
|
// SINGLETON AGGREGATION PATTERN
|
|
46
46
|
// This projection maintains a single document that aggregates data from multiple entities.
|
|
47
47
|
// Use internal state to track individual entity information for accurate calculations.
|
|
48
|
+
<% if (events.length === 1) { %>
|
|
49
|
+
type EntityData = <%= pascalCase(events[0].type) %>['data'];
|
|
50
|
+
<% } else { %>
|
|
51
|
+
type EntityData = Partial<<%= events.map(e => pascalCase(e.type) + "['data']").join(' & ') %>>;
|
|
52
|
+
<% } %>
|
|
53
|
+
|
|
48
54
|
interface Internal<%= pascalCase(targetName || 'State') %> extends <%= pascalCase(targetName || 'State') %> {
|
|
49
|
-
_entities?: Record<string,
|
|
55
|
+
_entities?: Record<string, EntityData>;
|
|
50
56
|
}
|
|
51
57
|
<% } %>
|
|
52
58
|
type AllEvents = <%= allEventTypes %>;
|