eva4j 1.0.11 → 1.0.13
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/AGENTS.md +441 -14
- package/DOMAIN_YAML_GUIDE.md +425 -21
- package/FUTURE_FEATURES.md +315 -115
- package/QUICK_REFERENCE.md +101 -153
- package/README.md +77 -70
- package/bin/eva4j.js +57 -1
- package/config/defaults.json +3 -0
- package/docs/commands/GENERATE_ENTITIES.md +662 -1968
- package/docs/commands/GENERATE_HTTP_EXCHANGE.md +274 -450
- package/docs/commands/GENERATE_KAFKA_EVENT.md +219 -498
- package/docs/commands/GENERATE_KAFKA_LISTENER.md +18 -18
- package/docs/commands/GENERATE_RECORD.md +335 -311
- package/docs/commands/GENERATE_TEMPORAL_ACTIVITY.md +174 -0
- package/docs/commands/GENERATE_TEMPORAL_FLOW.md +237 -0
- package/docs/commands/GENERATE_USECASE.md +216 -282
- package/docs/commands/INDEX.md +36 -7
- package/examples/doctor-evaluation.yaml +3 -3
- package/examples/domain-audit-complete.yaml +2 -2
- package/examples/domain-collections.yaml +2 -2
- package/examples/domain-ecommerce.yaml +2 -2
- package/examples/domain-events.yaml +201 -0
- package/examples/domain-field-visibility.yaml +11 -5
- package/examples/domain-multi-aggregate.yaml +12 -6
- package/examples/domain-one-to-many.yaml +1 -1
- package/examples/domain-one-to-one.yaml +1 -1
- package/examples/domain-secondary-onetomany.yaml +1 -1
- package/examples/domain-secondary-onetoone.yaml +1 -1
- package/examples/domain-simple.yaml +1 -1
- package/examples/domain-soft-delete.yaml +3 -3
- package/examples/domain-transitions.yaml +1 -1
- package/examples/domain-value-objects.yaml +1 -1
- package/package.json +2 -2
- package/src/commands/add-kafka-client.js +3 -1
- package/src/commands/add-temporal-client.js +286 -0
- package/src/commands/generate-entities.js +75 -4
- package/src/commands/generate-kafka-event.js +273 -89
- package/src/commands/generate-temporal-activity.js +228 -0
- package/src/commands/generate-temporal-flow.js +216 -0
- package/src/generators/module-generator.js +1 -0
- package/src/generators/shared-generator.js +26 -0
- package/src/utils/yaml-to-entity.js +93 -4
- package/templates/aggregate/AggregateRepository.java.ejs +3 -2
- package/templates/aggregate/AggregateRepositoryImpl.java.ejs +15 -7
- package/templates/aggregate/AggregateRoot.java.ejs +38 -2
- package/templates/aggregate/DomainEntity.java.ejs +6 -2
- package/templates/aggregate/DomainEventHandler.java.ejs +62 -0
- package/templates/aggregate/DomainEventRecord.java.ejs +50 -0
- package/templates/aggregate/JpaAggregateRoot.java.ejs +3 -1
- package/templates/aggregate/JpaEntity.java.ejs +3 -1
- package/templates/base/docker/kafka-services.yaml.ejs +2 -2
- package/templates/base/docker/temporal-services.yaml.ejs +29 -0
- package/templates/base/resources/parameters/develop/temporal.yaml.ejs +9 -0
- package/templates/base/resources/parameters/local/temporal.yaml.ejs +9 -0
- package/templates/base/resources/parameters/production/temporal.yaml.ejs +9 -0
- package/templates/base/resources/parameters/test/temporal.yaml.ejs +9 -0
- package/templates/base/root/AGENTS.md.ejs +916 -51
- package/templates/crud/Controller.java.ejs +36 -6
- package/templates/crud/ListQuery.java.ejs +6 -2
- package/templates/crud/ListQueryHandler.java.ejs +24 -10
- package/templates/crud/UpdateCommand.java.ejs +52 -0
- package/templates/crud/UpdateCommandHandler.java.ejs +105 -0
- package/templates/kafka-event/DomainEventHandlerMethod.ejs +1 -0
- package/templates/kafka-event/Event.java.ejs +23 -0
- package/templates/shared/application/dtos/PagedResponse.java.ejs +30 -0
- package/templates/shared/configurations/temporalConfig/TemporalConfig.java.ejs +104 -0
- package/templates/shared/domain/DomainEvent.java.ejs +40 -0
- package/templates/shared/interfaces/HeavyActivity.java.ejs +4 -0
- package/templates/shared/interfaces/LightActivity.java.ejs +4 -0
- package/templates/temporal-activity/ActivityImpl.java.ejs +14 -0
- package/templates/temporal-activity/ActivityInterface.java.ejs +11 -0
- package/templates/temporal-flow/WorkFlowImpl.java.ejs +64 -0
- package/templates/temporal-flow/WorkFlowInterface.java.ejs +19 -0
- package/templates/temporal-flow/WorkFlowService.java.ejs +49 -0
|
@@ -8,6 +8,7 @@ const ConfigManager = require('../utils/config-manager');
|
|
|
8
8
|
const { isEva4jProject, moduleExists } = require('../utils/validator');
|
|
9
9
|
const { toPackagePath, toPascalCase, toCamelCase, toSnakeCase, toKebabCase } = require('../utils/naming');
|
|
10
10
|
const { renderAndWrite, renderTemplate } = require('../utils/template-engine');
|
|
11
|
+
const { parseDomainYaml } = require('../utils/yaml-to-entity');
|
|
11
12
|
|
|
12
13
|
async function generateKafkaEventCommand(moduleName, eventName) {
|
|
13
14
|
const projectDir = process.cwd();
|
|
@@ -51,127 +52,190 @@ async function generateKafkaEventCommand(moduleName, eventName) {
|
|
|
51
52
|
process.exit(1);
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
//
|
|
55
|
-
|
|
55
|
+
// Try to read domain events declared in domain.yaml for the module
|
|
56
|
+
let domainEventChoices = [];
|
|
57
|
+
let domainEventMap = {};
|
|
58
|
+
const domainYamlPath = path.join(projectDir, 'src', 'main', 'java', packagePath, moduleName, 'domain.yaml');
|
|
59
|
+
if (await fs.pathExists(domainYamlPath)) {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = await parseDomainYaml(domainYamlPath, packageName, moduleName);
|
|
62
|
+
parsed.aggregates.forEach(agg => {
|
|
63
|
+
(agg.domainEvents || []).forEach(event => {
|
|
64
|
+
domainEventChoices.push({ name: `${event.name} (from ${agg.name} aggregate)`, value: event.name });
|
|
65
|
+
domainEventMap[event.name] = event;
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
} catch (_) {
|
|
69
|
+
// domain.yaml may not be parseable yet — silently fall back to free text
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Resolve list of event names to process ─────────────────────────────
|
|
74
|
+
let eventNames = [];
|
|
75
|
+
|
|
76
|
+
if (eventName) {
|
|
77
|
+
// Provided via CLI arg → single event
|
|
78
|
+
eventNames = [eventName];
|
|
79
|
+
} else if (domainEventChoices.length > 0) {
|
|
80
|
+
// Interactive multi-select from domain.yaml events
|
|
81
|
+
const choicesWithAll = [
|
|
82
|
+
{ name: chalk.bold('★ All events'), value: '__all__' },
|
|
83
|
+
new inquirer.Separator(),
|
|
84
|
+
...domainEventChoices,
|
|
85
|
+
new inquirer.Separator(),
|
|
86
|
+
{ name: 'Custom name (free text)...', value: '__custom__' }
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const { selectedEvents } = await inquirer.prompt([
|
|
90
|
+
{
|
|
91
|
+
type: 'checkbox',
|
|
92
|
+
name: 'selectedEvents',
|
|
93
|
+
message: 'Select domain events to publish via Kafka (space to select, enter to confirm):',
|
|
94
|
+
choices: choicesWithAll,
|
|
95
|
+
validate: (input) => input.length > 0 ? true : 'Select at least one event'
|
|
96
|
+
}
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
if (selectedEvents.includes('__all__')) {
|
|
100
|
+
// Expand to every declared domain event
|
|
101
|
+
eventNames = domainEventChoices.map(c => c.value);
|
|
102
|
+
} else if (selectedEvents.includes('__custom__')) {
|
|
103
|
+
const { customName } = await inquirer.prompt([
|
|
104
|
+
{
|
|
105
|
+
type: 'input',
|
|
106
|
+
name: 'customName',
|
|
107
|
+
message: 'Enter event name:',
|
|
108
|
+
validate: (input) => input && input.trim() !== '' ? true : 'Event name cannot be empty'
|
|
109
|
+
}
|
|
110
|
+
]);
|
|
111
|
+
eventNames = [customName];
|
|
112
|
+
} else {
|
|
113
|
+
eventNames = selectedEvents;
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
// No domain events in yaml → free-text fallback
|
|
56
117
|
const nameAnswer = await inquirer.prompt([
|
|
57
118
|
{
|
|
58
119
|
type: 'input',
|
|
59
120
|
name: 'eventName',
|
|
60
121
|
message: 'Enter event name:',
|
|
61
|
-
validate: (input) =>
|
|
62
|
-
if (!input || input.trim() === '') {
|
|
63
|
-
return 'Event name cannot be empty';
|
|
64
|
-
}
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
122
|
+
validate: (input) => input && input.trim() !== '' ? true : 'Event name cannot be empty'
|
|
67
123
|
}
|
|
68
124
|
]);
|
|
69
|
-
|
|
125
|
+
eventNames = [nameAnswer.eventName];
|
|
70
126
|
}
|
|
71
127
|
|
|
72
|
-
//
|
|
73
|
-
const normalizedEventName = toPascalCase(eventName);
|
|
74
|
-
const eventClassName = normalizedEventName.endsWith('Event')
|
|
75
|
-
? normalizedEventName
|
|
76
|
-
: `${normalizedEventName}Event`;
|
|
77
|
-
|
|
78
|
-
// Check if event already exists
|
|
79
|
-
const eventPath = path.join(projectDir, 'src', 'main', 'java', packagePath, moduleName, 'application', 'events', `${eventClassName}.java`);
|
|
80
|
-
if (await fs.pathExists(eventPath)) {
|
|
81
|
-
console.error(chalk.red(`❌ Event '${eventClassName}' already exists in module '${moduleName}'`));
|
|
82
|
-
process.exit(1);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Prompt for event configuration
|
|
128
|
+
// ── Shared configuration (applies to all selected events) ────────────────
|
|
86
129
|
const answers = await inquirer.prompt([
|
|
87
130
|
{
|
|
88
131
|
type: 'number',
|
|
89
132
|
name: 'partitions',
|
|
90
133
|
message: 'Number of partitions:',
|
|
91
134
|
default: 3,
|
|
92
|
-
validate: (value) =>
|
|
93
|
-
if (value < 1) return 'Partitions must be at least 1';
|
|
94
|
-
return true;
|
|
95
|
-
}
|
|
135
|
+
validate: (value) => value >= 1 ? true : 'Partitions must be at least 1'
|
|
96
136
|
},
|
|
97
137
|
{
|
|
98
138
|
type: 'number',
|
|
99
139
|
name: 'replicas',
|
|
100
140
|
message: 'Number of replicas:',
|
|
101
141
|
default: 1,
|
|
102
|
-
validate: (value) =>
|
|
103
|
-
if (value < 1) return 'Replicas must be at least 1';
|
|
104
|
-
return true;
|
|
105
|
-
}
|
|
142
|
+
validate: (value) => value >= 1 ? true : 'Replicas must be at least 1'
|
|
106
143
|
}
|
|
107
144
|
]);
|
|
108
145
|
|
|
109
146
|
const { partitions, replicas } = answers;
|
|
110
|
-
|
|
111
|
-
const spinner = ora(
|
|
147
|
+
const isBatch = eventNames.length > 1;
|
|
148
|
+
const spinner = ora(`Generating ${isBatch ? `${eventNames.length} Kafka events` : 'Kafka event'}...`).start();
|
|
149
|
+
const results = [];
|
|
112
150
|
|
|
113
151
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
topicNameKebab,
|
|
131
|
-
topicNameCamel,
|
|
132
|
-
topicPropertyKey,
|
|
133
|
-
topicPropertyValue,
|
|
134
|
-
topicSpringProperty,
|
|
135
|
-
partitions,
|
|
136
|
-
replicas
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
// 1. Generate Event Record
|
|
140
|
-
spinner.text = 'Generating event record...';
|
|
141
|
-
await generateEventRecord(projectDir, packagePath, context);
|
|
142
|
-
|
|
143
|
-
// 2. Update kafka.yaml files
|
|
144
|
-
spinner.text = 'Updating kafka.yaml configuration...';
|
|
145
|
-
await updateKafkaYml(projectDir, topicPropertyKey, topicPropertyValue);
|
|
146
|
-
|
|
147
|
-
// 3. Create/Update MessageBroker interface
|
|
148
|
-
spinner.text = 'Updating MessageBroker interface...';
|
|
149
|
-
await createOrUpdateMessageBroker(projectDir, packagePath, context);
|
|
150
|
-
|
|
151
|
-
// 4. Create/Update KafkaMessageBroker implementation
|
|
152
|
-
spinner.text = 'Updating KafkaMessageBroker implementation...';
|
|
153
|
-
await createOrUpdateKafkaMessageBroker(projectDir, packagePath, context);
|
|
154
|
-
|
|
155
|
-
// 5. Update KafkaConfig with NewTopic bean
|
|
156
|
-
spinner.text = 'Updating KafkaConfig...';
|
|
157
|
-
await updateKafkaConfig(projectDir, packagePath, context);
|
|
158
|
-
|
|
159
|
-
spinner.succeed(chalk.green('Kafka event generated successfully! ✨'));
|
|
152
|
+
for (const name of eventNames) {
|
|
153
|
+
const normalizedName = toPascalCase(name);
|
|
154
|
+
const evtClassName = normalizedName.endsWith('Event') ? normalizedName : `${normalizedName}Event`;
|
|
155
|
+
|
|
156
|
+
// In batch mode skip already-existing events; in single mode abort
|
|
157
|
+
const evtPath = path.join(projectDir, 'src', 'main', 'java', packagePath, moduleName, 'application', 'events', `${evtClassName}.java`);
|
|
158
|
+
if (await fs.pathExists(evtPath)) {
|
|
159
|
+
if (isBatch) {
|
|
160
|
+
results.push({ name: evtClassName, skipped: true });
|
|
161
|
+
continue;
|
|
162
|
+
} else {
|
|
163
|
+
spinner.fail();
|
|
164
|
+
console.error(chalk.red(`❌ Event '${evtClassName}' already exists in module '${moduleName}'`));
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
160
168
|
|
|
169
|
+
const topicNameKebab = toKebabCase(name);
|
|
170
|
+
const topicNameCamel = toCamelCase(name);
|
|
171
|
+
const topicNameSnake = toSnakeCase(name).toUpperCase();
|
|
172
|
+
const topicSpringProperty = `\${topics.${topicNameKebab}}`;
|
|
173
|
+
const selectedDomainEvent = domainEventMap[normalizedName] || null;
|
|
174
|
+
|
|
175
|
+
const context = {
|
|
176
|
+
packageName,
|
|
177
|
+
moduleName,
|
|
178
|
+
modulePascalCase: toPascalCase(moduleName),
|
|
179
|
+
moduleCamelCase: toCamelCase(moduleName),
|
|
180
|
+
kafkaMessageBrokerClassName: `${toPascalCase(moduleName)}KafkaMessageBroker`,
|
|
181
|
+
eventClassName: evtClassName,
|
|
182
|
+
topicNameSnake,
|
|
183
|
+
topicNameKebab,
|
|
184
|
+
topicNameCamel,
|
|
185
|
+
topicPropertyKey: topicNameKebab,
|
|
186
|
+
topicPropertyValue: topicNameSnake,
|
|
187
|
+
topicSpringProperty,
|
|
188
|
+
partitions,
|
|
189
|
+
replicas,
|
|
190
|
+
eventFields: selectedDomainEvent ? selectedDomainEvent.fields : null
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
if (isBatch) spinner.text = `[${results.length + 1}/${eventNames.length}] Generating ${evtClassName}...`;
|
|
194
|
+
|
|
195
|
+
await generateSingleKafkaEvent(projectDir, packagePath, context);
|
|
196
|
+
results.push({ name: evtClassName, skipped: false, handlerUpdated: context._handlerUpdated });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const generated = results.filter(r => !r.skipped);
|
|
200
|
+
spinner.succeed(chalk.green(`${isBatch ? `${generated.length} Kafka events` : 'Kafka event'} generated successfully! ✨`));
|
|
201
|
+
|
|
202
|
+
const kafkaMessageBrokerClass = `${toPascalCase(moduleName)}KafkaMessageBroker`;
|
|
161
203
|
console.log(chalk.blue('\n📦 Generated/Updated components:'));
|
|
162
|
-
|
|
204
|
+
results.forEach((r) => {
|
|
205
|
+
if (r.skipped) {
|
|
206
|
+
console.log(chalk.yellow(` ├── ${moduleName}/application/events/${r.name}.java (skipped — already exists)`));
|
|
207
|
+
} else {
|
|
208
|
+
console.log(chalk.gray(` ├── ${moduleName}/application/events/${r.name}.java`));
|
|
209
|
+
}
|
|
210
|
+
});
|
|
163
211
|
console.log(chalk.gray(` ├── ${moduleName}/application/ports/MessageBroker.java`));
|
|
164
|
-
console.log(chalk.gray(` ├── ${moduleName}/infrastructure/adapters/kafkaMessageBroker/${
|
|
212
|
+
console.log(chalk.gray(` ├── ${moduleName}/infrastructure/adapters/kafkaMessageBroker/${kafkaMessageBrokerClass}.java`));
|
|
165
213
|
console.log(chalk.gray(` ├── shared/configurations/kafkaConfig/KafkaConfig.java`));
|
|
214
|
+
if (results.some(r => r.handlerUpdated)) {
|
|
215
|
+
console.log(chalk.gray(` ├── ${moduleName}/application/usecases/*DomainEventHandler.java`));
|
|
216
|
+
}
|
|
166
217
|
console.log(chalk.gray(' └── parameters/*/kafka.yaml (all environments)'));
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
218
|
+
|
|
219
|
+
if (!isBatch && generated.length === 1) {
|
|
220
|
+
const r = generated[0];
|
|
221
|
+
const topicSnake = toSnakeCase(eventNames[0]).toUpperCase();
|
|
222
|
+
const topicKebab = toKebabCase(eventNames[0]);
|
|
223
|
+
console.log(chalk.blue('\n✅ Kafka event configured successfully!'));
|
|
224
|
+
console.log(chalk.white(`\n Event: ${r.name}`));
|
|
225
|
+
console.log(chalk.white(` Topic: ${topicSnake} (${topicKebab})`));
|
|
226
|
+
console.log(chalk.white(` Partitions: ${partitions}`));
|
|
227
|
+
console.log(chalk.white(` Replicas: ${replicas}`));
|
|
228
|
+
console.log(chalk.gray('\n You can now inject MessageBroker in your services and call:'));
|
|
229
|
+
console.log(chalk.gray(` messageBroker.publish${r.name}(event);\n`));
|
|
230
|
+
} else {
|
|
231
|
+
console.log(chalk.blue('\n✅ All Kafka events configured successfully!'));
|
|
232
|
+
console.log(chalk.white(`\n Partitions: ${partitions} | Replicas: ${replicas}`));
|
|
233
|
+
if (generated.length > 0) {
|
|
234
|
+
console.log(chalk.gray('\n Available MessageBroker methods:'));
|
|
235
|
+
generated.forEach(r => console.log(chalk.gray(` messageBroker.publish${r.name}(event);`)));
|
|
236
|
+
}
|
|
237
|
+
console.log('');
|
|
238
|
+
}
|
|
175
239
|
|
|
176
240
|
} catch (error) {
|
|
177
241
|
spinner.fail(chalk.red('Failed to generate Kafka event'));
|
|
@@ -183,6 +247,19 @@ async function generateKafkaEventCommand(moduleName, eventName) {
|
|
|
183
247
|
}
|
|
184
248
|
}
|
|
185
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Run the full generation pipeline for a single event.
|
|
252
|
+
* Mutates context._handlerUpdated to signal whether the DomainEventHandler was wired.
|
|
253
|
+
*/
|
|
254
|
+
async function generateSingleKafkaEvent(projectDir, packagePath, context) {
|
|
255
|
+
await generateEventRecord(projectDir, packagePath, context);
|
|
256
|
+
await updateKafkaYml(projectDir, context.topicPropertyKey, context.topicPropertyValue);
|
|
257
|
+
await createOrUpdateMessageBroker(projectDir, packagePath, context);
|
|
258
|
+
await createOrUpdateKafkaMessageBroker(projectDir, packagePath, context);
|
|
259
|
+
await updateKafkaConfig(projectDir, packagePath, context);
|
|
260
|
+
context._handlerUpdated = await updateDomainEventHandler(projectDir, packagePath, context);
|
|
261
|
+
}
|
|
262
|
+
|
|
186
263
|
/**
|
|
187
264
|
* Generate Event Record
|
|
188
265
|
*/
|
|
@@ -455,4 +532,111 @@ async function updateKafkaConfig(projectDir, packagePath, context) {
|
|
|
455
532
|
await fs.writeFile(configPath, content, 'utf-8');
|
|
456
533
|
}
|
|
457
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Update DomainEventHandler: inject MessageBroker dependency and replace TODO with real mapping call.
|
|
537
|
+
* Returns true if the handler was found and updated, false if skipped.
|
|
538
|
+
*/
|
|
539
|
+
async function updateDomainEventHandler(projectDir, packagePath, context) {
|
|
540
|
+
const handlerDir = path.join(
|
|
541
|
+
projectDir, 'src', 'main', 'java', packagePath,
|
|
542
|
+
context.moduleName, 'application', 'usecases'
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
if (!(await fs.pathExists(handlerDir))) {
|
|
546
|
+
console.log(chalk.yellow(`\n ⚠️ No usecases directory found — skipping handler update`));
|
|
547
|
+
console.log(chalk.gray(` Run 'eva4j g entities ${context.moduleName}' first to generate the DomainEventHandler`));
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const files = await fs.readdir(handlerDir);
|
|
552
|
+
const handlerFile = files.find(f => f.endsWith('DomainEventHandler.java'));
|
|
553
|
+
|
|
554
|
+
if (!handlerFile) {
|
|
555
|
+
console.log(chalk.yellow(`\n ⚠️ DomainEventHandler not found in ${context.moduleName}/application/usecases/ — skipping`));
|
|
556
|
+
console.log(chalk.gray(` Run 'eva4j g entities ${context.moduleName}' first`));
|
|
557
|
+
return false;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const handlerPath = path.join(handlerDir, handlerFile);
|
|
561
|
+
const handlerClassName = handlerFile.replace('.java', '');
|
|
562
|
+
let content = await fs.readFile(handlerPath, 'utf-8');
|
|
563
|
+
|
|
564
|
+
// Idempotency: bail if the publish call is already there
|
|
565
|
+
if (content.includes(`publish${context.eventClassName}`)) {
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Compute domain event name by stripping 'Event' suffix from eventClassName
|
|
570
|
+
// e.g. OrderPlacedEvent → OrderPlaced
|
|
571
|
+
const domainEventName = context.eventClassName.endsWith('Event')
|
|
572
|
+
? context.eventClassName.slice(0, -'Event'.length)
|
|
573
|
+
: context.eventClassName;
|
|
574
|
+
|
|
575
|
+
// Guard: the TODO comment must exist (generated by g entities)
|
|
576
|
+
if (!content.includes(`// TODO: handle ${domainEventName}`)) {
|
|
577
|
+
console.log(chalk.yellow(`\n ⚠️ No TODO handler found for '${domainEventName}' in ${handlerFile} — skipping`));
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// 1. Inject import for the application-layer event record
|
|
582
|
+
const eventImport = `import ${context.packageName}.${context.moduleName}.application.events.${context.eventClassName};`;
|
|
583
|
+
if (!content.includes(eventImport)) {
|
|
584
|
+
content = injectImportIntoFile(content, eventImport);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// 2. Inject import for the MessageBroker port
|
|
588
|
+
const brokerImport = `import ${context.packageName}.${context.moduleName}.application.ports.MessageBroker;`;
|
|
589
|
+
if (!content.includes(brokerImport)) {
|
|
590
|
+
content = injectImportIntoFile(content, brokerImport);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// 3. Inject field + constructor if MessageBroker is not yet present
|
|
594
|
+
if (!content.includes('private final MessageBroker messageBroker;')) {
|
|
595
|
+
const classPattern = /(public\s+class\s+\w+DomainEventHandler\s*\{\s*\n)/;
|
|
596
|
+
const classMatch = content.match(classPattern);
|
|
597
|
+
if (classMatch) {
|
|
598
|
+
const insertPos = classMatch.index + classMatch[0].length;
|
|
599
|
+
const fieldAndCtor =
|
|
600
|
+
`\n private final MessageBroker messageBroker;\n` +
|
|
601
|
+
`\n public ${handlerClassName}(MessageBroker messageBroker) {\n` +
|
|
602
|
+
` this.messageBroker = messageBroker;\n` +
|
|
603
|
+
` }\n`;
|
|
604
|
+
content = content.slice(0, insertPos) + fieldAndCtor + content.slice(insertPos);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// 4. Render the mapping call and replace the TODO block
|
|
609
|
+
const templatePath = path.join(__dirname, '..', '..', 'templates', 'kafka-event', 'DomainEventHandlerMethod.ejs');
|
|
610
|
+
const mappingLine = await renderTemplate(templatePath, { ...context, domainEventFields: context.eventFields });
|
|
611
|
+
|
|
612
|
+
const todoRegex = new RegExp(
|
|
613
|
+
`([ \\t]*\/\/ TODO: handle ${domainEventName}[^\\n]*\\n)(?:[ \\t]*\/\/[^\\n]*\\n)*`
|
|
614
|
+
);
|
|
615
|
+
content = content.replace(todoRegex, ` ${mappingLine.trim()}\n`);
|
|
616
|
+
|
|
617
|
+
await fs.writeFile(handlerPath, content, 'utf-8');
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Injects an import statement after the last existing import, or after the package declaration.
|
|
623
|
+
*/
|
|
624
|
+
function injectImportIntoFile(content, importStatement) {
|
|
625
|
+
const packageMatch = content.match(/(package\s+[\w.]+;\s*\n)/);
|
|
626
|
+
if (!packageMatch) return content;
|
|
627
|
+
|
|
628
|
+
const hasImports = /import\s+[\w.]+;/.test(content);
|
|
629
|
+
if (hasImports) {
|
|
630
|
+
const imports = content.matchAll(/import\s+[\w.]+;\s*\n/g);
|
|
631
|
+
let lastImportEnd = packageMatch.index + packageMatch[0].length;
|
|
632
|
+
for (const match of imports) {
|
|
633
|
+
lastImportEnd = match.index + match[0].length;
|
|
634
|
+
}
|
|
635
|
+
return content.slice(0, lastImportEnd) + importStatement + '\n' + content.slice(lastImportEnd);
|
|
636
|
+
} else {
|
|
637
|
+
const insertPos = packageMatch.index + packageMatch[0].length;
|
|
638
|
+
return content.slice(0, insertPos) + '\n' + importStatement + '\n' + content.slice(insertPos);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
458
642
|
module.exports = generateKafkaEventCommand;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
const inquirer = require('inquirer');
|
|
2
|
+
const ora = require('ora');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs-extra');
|
|
6
|
+
const ConfigManager = require('../utils/config-manager');
|
|
7
|
+
const { isEva4jProject, moduleExists } = require('../utils/validator');
|
|
8
|
+
const { toPackagePath, toPascalCase } = require('../utils/naming');
|
|
9
|
+
const { renderAndWrite } = require('../utils/template-engine');
|
|
10
|
+
const ChecksumManager = require('../utils/checksum-manager');
|
|
11
|
+
|
|
12
|
+
async function generateTemporalActivityCommand(moduleName, activityName, options = {}) {
|
|
13
|
+
const projectDir = process.cwd();
|
|
14
|
+
|
|
15
|
+
if (!(await isEva4jProject(projectDir))) {
|
|
16
|
+
console.error(chalk.red('❌ Not in an eva4j project directory'));
|
|
17
|
+
console.error(chalk.gray('Run this command inside a project created with eva4j'));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const configManager = new ConfigManager(projectDir);
|
|
22
|
+
|
|
23
|
+
if (!(await configManager.featureExists('temporal'))) {
|
|
24
|
+
console.error(chalk.red('❌ Temporal client is not installed in this project'));
|
|
25
|
+
console.error(chalk.gray('Install Temporal first using: eva add temporal-client'));
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!moduleName) {
|
|
30
|
+
const answer = await inquirer.prompt([{
|
|
31
|
+
type: 'input',
|
|
32
|
+
name: 'moduleName',
|
|
33
|
+
message: 'Module name:',
|
|
34
|
+
validate: (v) => (v.trim() ? true : 'Module name is required'),
|
|
35
|
+
}]);
|
|
36
|
+
moduleName = answer.moduleName.trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const projectConfig = await configManager.loadProjectConfig();
|
|
40
|
+
if (!projectConfig) {
|
|
41
|
+
console.error(chalk.red('❌ Could not load project configuration'));
|
|
42
|
+
console.error(chalk.gray('Make sure .eva4j.json exists in the project root'));
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { packageName } = projectConfig;
|
|
47
|
+
const packagePath = toPackagePath(packageName);
|
|
48
|
+
|
|
49
|
+
if (!(await configManager.moduleExists(moduleName))) {
|
|
50
|
+
console.error(chalk.red(`❌ Module '${moduleName}' does not exist`));
|
|
51
|
+
console.error(chalk.gray(`Create it first using: eva add module ${moduleName}`));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!(await moduleExists(projectDir, packagePath, moduleName))) {
|
|
56
|
+
console.error(chalk.red(`❌ Module directory for '${moduleName}' not found`));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 1. Prompt activity name
|
|
61
|
+
if (!activityName) {
|
|
62
|
+
const answer = await inquirer.prompt([{
|
|
63
|
+
type: 'input',
|
|
64
|
+
name: 'activityName',
|
|
65
|
+
message: 'Activity name (e.g. register-order, ValidatePayment):',
|
|
66
|
+
validate: (v) => (v.trim() ? true : 'Activity name is required'),
|
|
67
|
+
}]);
|
|
68
|
+
activityName = answer.activityName.trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const activityPascalCase = toPascalCase(activityName);
|
|
72
|
+
|
|
73
|
+
// 2. Prompt activity category
|
|
74
|
+
const { activityCategory } = await inquirer.prompt([{
|
|
75
|
+
type: 'list',
|
|
76
|
+
name: 'activityCategory',
|
|
77
|
+
message: 'Activity category:',
|
|
78
|
+
choices: [
|
|
79
|
+
{ name: 'LightActivity (fast, low-resource tasks)', value: 'LightActivity' },
|
|
80
|
+
{ name: 'HeavyActivity (long-running, resource-intensive tasks)', value: 'HeavyActivity' },
|
|
81
|
+
],
|
|
82
|
+
}]);
|
|
83
|
+
|
|
84
|
+
// 3. Discover existing workflows across all modules
|
|
85
|
+
const javaRoot = path.join(projectDir, 'src', 'main', 'java', packagePath);
|
|
86
|
+
const workflows = await findExistingWorkflows(javaRoot);
|
|
87
|
+
|
|
88
|
+
if (workflows.length === 0) {
|
|
89
|
+
console.error(chalk.red('❌ No workflows found in this project'));
|
|
90
|
+
console.error(chalk.gray('Create a workflow first using: eva generate temporal-flow <module>'));
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { selectedWorkflow } = await inquirer.prompt([{
|
|
95
|
+
type: 'list',
|
|
96
|
+
name: 'selectedWorkflow',
|
|
97
|
+
message: 'Register activity in workflow:',
|
|
98
|
+
choices: workflows.map((w) => ({ name: `${w.moduleName} / ${w.implClass}`, value: w })),
|
|
99
|
+
}]);
|
|
100
|
+
|
|
101
|
+
const moduleBasePath = path.join(projectDir, 'src', 'main', 'java', packagePath, moduleName);
|
|
102
|
+
const checksumManager = new ChecksumManager(moduleBasePath);
|
|
103
|
+
await checksumManager.load();
|
|
104
|
+
|
|
105
|
+
const spinner = ora(`Generating ${activityPascalCase}Activity...`).start();
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const context = { packageName, moduleName, activityPascalCase, activityCategory };
|
|
109
|
+
const templatesDir = path.join(__dirname, '..', '..', 'templates', 'temporal-activity');
|
|
110
|
+
const writeOptions = { force: options.force, checksumManager };
|
|
111
|
+
|
|
112
|
+
// 4. Generate ActivityInterface in application/ports/
|
|
113
|
+
spinner.text = `Generating ${activityPascalCase}Activity interface...`;
|
|
114
|
+
await renderAndWrite(
|
|
115
|
+
path.join(templatesDir, 'ActivityInterface.java.ejs'),
|
|
116
|
+
path.join(moduleBasePath, 'application', 'ports', `${activityPascalCase}Activity.java`),
|
|
117
|
+
context,
|
|
118
|
+
writeOptions
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// 5. Generate ActivityImpl in infrastructure/adapters/activities/
|
|
122
|
+
spinner.text = `Generating ${activityPascalCase}ActivityImpl...`;
|
|
123
|
+
await renderAndWrite(
|
|
124
|
+
path.join(templatesDir, 'ActivityImpl.java.ejs'),
|
|
125
|
+
path.join(moduleBasePath, 'infrastructure', 'adapters', 'activities', `${activityPascalCase}ActivityImpl.java`),
|
|
126
|
+
context,
|
|
127
|
+
writeOptions
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// 6. Register activity stub in selected WorkFlowImpl
|
|
131
|
+
spinner.text = `Registering activity in ${selectedWorkflow.implClass}...`;
|
|
132
|
+
await registerActivityInWorkflow(
|
|
133
|
+
selectedWorkflow.filePath,
|
|
134
|
+
packageName,
|
|
135
|
+
moduleName,
|
|
136
|
+
activityPascalCase,
|
|
137
|
+
activityCategory
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
spinner.succeed(chalk.green(`✅ ${activityPascalCase}Activity generated successfully`));
|
|
141
|
+
|
|
142
|
+
console.log(chalk.blue('\n📁 Generated files:'));
|
|
143
|
+
console.log(chalk.gray(` ${moduleName}/application/ports/${activityPascalCase}Activity.java`));
|
|
144
|
+
console.log(chalk.gray(` ${moduleName}/infrastructure/adapters/activities/${activityPascalCase}ActivityImpl.java`));
|
|
145
|
+
console.log(chalk.blue('\n📝 Updated files:'));
|
|
146
|
+
console.log(chalk.gray(` ${selectedWorkflow.moduleName}/application/usecases/${selectedWorkflow.implClass}.java`));
|
|
147
|
+
|
|
148
|
+
await checksumManager.save();
|
|
149
|
+
} catch (error) {
|
|
150
|
+
spinner.fail(chalk.red('Failed to generate temporal activity'));
|
|
151
|
+
console.error(chalk.red(error.message));
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function findExistingWorkflows(javaRoot) {
|
|
157
|
+
const results = [];
|
|
158
|
+
if (!(await fs.pathExists(javaRoot))) return results;
|
|
159
|
+
|
|
160
|
+
const modules = await fs.readdir(javaRoot);
|
|
161
|
+
for (const mod of modules) {
|
|
162
|
+
const usecasesDir = path.join(javaRoot, mod, 'application', 'usecases');
|
|
163
|
+
if (!(await fs.pathExists(usecasesDir))) continue;
|
|
164
|
+
const files = await fs.readdir(usecasesDir);
|
|
165
|
+
for (const file of files) {
|
|
166
|
+
if (file.endsWith('WorkFlowImpl.java')) {
|
|
167
|
+
results.push({
|
|
168
|
+
moduleName: mod,
|
|
169
|
+
implClass: file.replace('.java', ''),
|
|
170
|
+
filePath: path.join(usecasesDir, file),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return results;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function registerActivityInWorkflow(workflowImplPath, packageName, moduleName, activityPascalCase, activityCategory) {
|
|
179
|
+
if (!(await fs.pathExists(workflowImplPath))) return;
|
|
180
|
+
|
|
181
|
+
let content = await fs.readFile(workflowImplPath, 'utf-8');
|
|
182
|
+
|
|
183
|
+
const activityInterface = `${activityPascalCase}Activity`;
|
|
184
|
+
const activityField = `${activityPascalCase.charAt(0).toLowerCase()}${activityPascalCase.slice(1)}Activity`;
|
|
185
|
+
const importLine = `import ${packageName}.${moduleName}.application.ports.${activityInterface};`;
|
|
186
|
+
const optionsVar = activityCategory === 'HeavyActivity' ? 'heavyActivityOptions' : 'lightActivityOptions';
|
|
187
|
+
const fieldLine = ` private final ${activityInterface} ${activityField} = Workflow.newActivityStub(${activityInterface}.class, ${optionsVar});`;
|
|
188
|
+
|
|
189
|
+
// Skip if already registered
|
|
190
|
+
if (content.includes(fieldLine)) return;
|
|
191
|
+
|
|
192
|
+
// Add import after last import line
|
|
193
|
+
if (!content.includes(importLine)) {
|
|
194
|
+
const allImports = [...content.matchAll(/^import .+;/gm)];
|
|
195
|
+
if (allImports.length > 0) {
|
|
196
|
+
const lastImport = allImports[allImports.length - 1];
|
|
197
|
+
const insertPos = lastImport.index + lastImport[0].length;
|
|
198
|
+
content = content.slice(0, insertPos) + '\n' + importLine + content.slice(insertPos);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Insert field after last activityOptions block (or append after last existing stub)
|
|
203
|
+
const activityOptionsRegex = /(private final ActivityOptions heavyActivityOptions = ActivityOptions\.newBuilder\(\)[\s\S]*?\.build\(\)\s*\)\.build\(\);)/;
|
|
204
|
+
if (content.includes('//register activities')) {
|
|
205
|
+
// Append after the last existing activity stub
|
|
206
|
+
const lastStubRegex = /(private final \w+Activity \w+Activity = Workflow\.newActivityStub\([^;]+;)/g;
|
|
207
|
+
const allStubs = [...content.matchAll(lastStubRegex)];
|
|
208
|
+
if (allStubs.length > 0) {
|
|
209
|
+
const lastStub = allStubs[allStubs.length - 1];
|
|
210
|
+
const insertPos = lastStub.index + lastStub[0].length;
|
|
211
|
+
content = content.slice(0, insertPos) + '\n' + fieldLine + content.slice(insertPos);
|
|
212
|
+
}
|
|
213
|
+
} else if (activityOptionsRegex.test(content)) {
|
|
214
|
+
content = content.replace(activityOptionsRegex, (match) => {
|
|
215
|
+
return match + '\n\n //register activities\n' + fieldLine;
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
// Fallback: insert before the first @Override
|
|
219
|
+
content = content.replace(
|
|
220
|
+
/( {4}@Override)/,
|
|
221
|
+
` //register activities\n${fieldLine}\n\n$1`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
await fs.writeFile(workflowImplPath, content, 'utf-8');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
module.exports = generateTemporalActivityCommand;
|