eva4j 1.0.13 → 1.0.15

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 (106) hide show
  1. package/AGENTS.md +314 -10
  2. package/COMMAND_EVALUATION.md +15 -16
  3. package/DOMAIN_YAML_GUIDE.md +576 -10
  4. package/FUTURE_FEATURES.md +1627 -1168
  5. package/README.md +318 -13
  6. package/bin/eva4j.js +34 -0
  7. package/config/defaults.json +1 -0
  8. package/design-system.md +797 -0
  9. package/docs/commands/EVALUATE_SYSTEM.md +994 -0
  10. package/docs/commands/GENERATE_ENTITIES.md +795 -6
  11. package/docs/commands/INDEX.md +10 -1
  12. package/examples/domain-endpoints-relations.yaml +353 -0
  13. package/examples/domain-endpoints-versioned.yaml +144 -0
  14. package/examples/domain-endpoints.yaml +135 -0
  15. package/examples/domain-events.yaml +166 -20
  16. package/examples/domain-listeners.yaml +212 -0
  17. package/examples/domain-one-to-many.yaml +1 -0
  18. package/examples/domain-one-to-one.yaml +1 -0
  19. package/examples/domain-ports.yaml +414 -0
  20. package/examples/domain-soft-delete.yaml +47 -44
  21. package/examples/system/notification.yaml +147 -0
  22. package/examples/system/product.yaml +185 -0
  23. package/examples/system/system.yaml +112 -0
  24. package/examples/system-report.html +971 -0
  25. package/examples/system.yaml +332 -0
  26. package/package.json +2 -1
  27. package/src/commands/build.js +714 -0
  28. package/src/commands/create.js +7 -3
  29. package/src/commands/detach.js +1 -0
  30. package/src/commands/evaluate-system.js +610 -0
  31. package/src/commands/generate-entities.js +1331 -49
  32. package/src/commands/generate-http-exchange.js +2 -0
  33. package/src/commands/generate-kafka-event.js +98 -11
  34. package/src/generators/base-generator.js +8 -1
  35. package/src/generators/postman-generator.js +188 -0
  36. package/src/generators/shared-generator.js +10 -0
  37. package/src/utils/config-manager.js +54 -0
  38. package/src/utils/context-builder.js +1 -0
  39. package/src/utils/domain-diagram.js +192 -0
  40. package/src/utils/domain-validator.js +970 -0
  41. package/src/utils/fake-data.js +376 -0
  42. package/src/utils/naming.js +3 -2
  43. package/src/utils/system-validator.js +434 -0
  44. package/src/utils/yaml-to-entity.js +302 -8
  45. package/templates/aggregate/AggregateMapper.java.ejs +3 -2
  46. package/templates/aggregate/AggregateRepository.java.ejs +8 -2
  47. package/templates/aggregate/AggregateRepositoryImpl.java.ejs +13 -3
  48. package/templates/aggregate/AggregateRoot.java.ejs +60 -2
  49. package/templates/aggregate/DomainEventHandler.java.ejs +27 -20
  50. package/templates/aggregate/DomainEventRecord.java.ejs +24 -8
  51. package/templates/aggregate/DomainEventSnapshot.java.ejs +46 -0
  52. package/templates/aggregate/JpaAggregateRoot.java.ejs +6 -0
  53. package/templates/aggregate/JpaRepository.java.ejs +5 -0
  54. package/templates/base/gradle/build.gradle.ejs +3 -2
  55. package/templates/base/root/AGENTS.md.ejs +306 -45
  56. package/templates/base/root/skill-build-domain-yaml-references-generate-entities.md.ejs +1663 -0
  57. package/templates/base/root/skill-build-system-yaml.ejs +1446 -0
  58. package/templates/base/root/system.yaml.ejs +97 -0
  59. package/templates/crud/ApplicationMapper.java.ejs +4 -0
  60. package/templates/crud/Controller.java.ejs +4 -4
  61. package/templates/crud/CreateCommand.java.ejs +4 -0
  62. package/templates/crud/CreateItemDto.java.ejs +4 -0
  63. package/templates/crud/CreateValueObjectDto.java.ejs +4 -0
  64. package/templates/crud/DeleteCommandHandler.java.ejs +10 -2
  65. package/templates/crud/EndpointsController.java.ejs +178 -0
  66. package/templates/crud/FindByQuery.java.ejs +17 -0
  67. package/templates/crud/FindByQueryHandler.java.ejs +57 -0
  68. package/templates/crud/ListQuery.java.ejs +1 -1
  69. package/templates/crud/ListQueryHandler.java.ejs +8 -8
  70. package/templates/crud/ScaffoldCommand.java.ejs +12 -0
  71. package/templates/crud/ScaffoldCommandHandler.java.ejs +43 -0
  72. package/templates/crud/ScaffoldQuery.java.ejs +13 -0
  73. package/templates/crud/ScaffoldQueryHandler.java.ejs +41 -0
  74. package/templates/crud/SubEntityAddCommand.java.ejs +21 -0
  75. package/templates/crud/SubEntityAddCommandHandler.java.ejs +43 -0
  76. package/templates/crud/SubEntityRemoveCommand.java.ejs +9 -0
  77. package/templates/crud/SubEntityRemoveCommandHandler.java.ejs +42 -0
  78. package/templates/crud/TransitionCommand.java.ejs +9 -0
  79. package/templates/crud/TransitionCommandHandler.java.ejs +39 -0
  80. package/templates/crud/UpdateCommand.java.ejs +4 -0
  81. package/templates/evaluate/report.html.ejs +1363 -0
  82. package/templates/kafka-event/DomainEventHandlerMethod.ejs +3 -1
  83. package/templates/kafka-event/Event.java.ejs +16 -0
  84. package/templates/kafka-listener/KafkaController.java.ejs +1 -1
  85. package/templates/kafka-listener/KafkaListenerClass.java.ejs +1 -1
  86. package/templates/kafka-listener/ListenerClass.java.ejs +65 -0
  87. package/templates/kafka-listener/ListenerCommand.java.ejs +31 -0
  88. package/templates/kafka-listener/ListenerCommandHandler.java.ejs +23 -0
  89. package/templates/kafka-listener/ListenerIntegrationEvent.java.ejs +37 -0
  90. package/templates/kafka-listener/ListenerMethod.java.ejs +1 -1
  91. package/templates/kafka-listener/ListenerNestedType.java.ejs +28 -0
  92. package/templates/mock/MockEvent.java.ejs +10 -0
  93. package/templates/mock/MockMessageBrokerImpl.java.ejs +35 -0
  94. package/templates/mock/MockMessageBrokerImplMethod.java.ejs +6 -0
  95. package/templates/mock/SpringEventListener.java.ejs +61 -0
  96. package/templates/ports/PortDomainModel.java.ejs +35 -0
  97. package/templates/ports/PortFeignAdapter.java.ejs +67 -0
  98. package/templates/ports/PortFeignClient.java.ejs +45 -0
  99. package/templates/ports/PortFeignConfig.java.ejs +24 -0
  100. package/templates/ports/PortInterface.java.ejs +45 -0
  101. package/templates/ports/PortNestedType.java.ejs +28 -0
  102. package/templates/ports/PortRequestDto.java.ejs +30 -0
  103. package/templates/ports/PortResponseDto.java.ejs +28 -0
  104. package/templates/postman/Collection.json.ejs +1 -1
  105. package/templates/postman/UnifiedCollection.json.ejs +185 -0
  106. package/templates/shared/configurations/eventPublicationConfig/EventPublicationSchemaConfig.java.ejs +109 -0
@@ -307,3 +307,5 @@ async function ensureUrlsImport(projectDir) {
307
307
  }
308
308
 
309
309
  module.exports = generateHttpExchangeCommand;
310
+ module.exports.createOrUpdateUrlsConfig = createOrUpdateUrlsConfig;
311
+ module.exports.ensureUrlsImport = ensureUrlsImport;
@@ -151,7 +151,7 @@ async function generateKafkaEventCommand(moduleName, eventName) {
151
151
  try {
152
152
  for (const name of eventNames) {
153
153
  const normalizedName = toPascalCase(name);
154
- const evtClassName = normalizedName.endsWith('Event') ? normalizedName : `${normalizedName}Event`;
154
+ const evtClassName = normalizedName.endsWith('IntegrationEvent') ? normalizedName : `${normalizedName}IntegrationEvent`;
155
155
 
156
156
  // In batch mode skip already-existing events; in single mode abort
157
157
  const evtPath = path.join(projectDir, 'src', 'main', 'java', packagePath, moduleName, 'application', 'events', `${evtClassName}.java`);
@@ -166,9 +166,9 @@ async function generateKafkaEventCommand(moduleName, eventName) {
166
166
  }
167
167
  }
168
168
 
169
- const topicNameKebab = toKebabCase(name);
170
- const topicNameCamel = toCamelCase(name);
171
- const topicNameSnake = toSnakeCase(name).toUpperCase();
169
+ const topicNameKebab = toKebabCase(stripEventSuffix(name));
170
+ const topicNameCamel = toCamelCase(stripEventSuffix(name));
171
+ const topicNameSnake = toSnakeCase(stripEventSuffix(name)).toUpperCase();
172
172
  const topicSpringProperty = `\${topics.${topicNameKebab}}`;
173
173
  const selectedDomainEvent = domainEventMap[normalizedName] || null;
174
174
 
@@ -218,8 +218,8 @@ async function generateKafkaEventCommand(moduleName, eventName) {
218
218
 
219
219
  if (!isBatch && generated.length === 1) {
220
220
  const r = generated[0];
221
- const topicSnake = toSnakeCase(eventNames[0]).toUpperCase();
222
- const topicKebab = toKebabCase(eventNames[0]);
221
+ const topicSnake = toSnakeCase(stripEventSuffix(eventNames[0])).toUpperCase();
222
+ const topicKebab = toKebabCase(stripEventSuffix(eventNames[0]));
223
223
  console.log(chalk.blue('\n✅ Kafka event configured successfully!'));
224
224
  console.log(chalk.white(`\n Event: ${r.name}`));
225
225
  console.log(chalk.white(` Topic: ${topicSnake} (${topicKebab})`));
@@ -393,6 +393,15 @@ async function createOrUpdateKafkaMessageBroker(projectDir, packagePath, context
393
393
  // Update existing implementation
394
394
  let content = await fs.readFile(adapterPath, 'utf-8');
395
395
 
396
+ // If this is a mock impl (generated by build --mock), replace it wholesale
397
+ // with the real Kafka implementation before proceeding.
398
+ const isMockImpl = content.includes('ApplicationEventPublisher') && !content.includes('KafkaTemplate');
399
+ if (isMockImpl) {
400
+ const templatePath = path.join(__dirname, '..', '..', 'templates', 'kafka-event', 'KafkaMessageBroker.java.ejs');
401
+ await renderAndWrite(templatePath, adapterPath, context);
402
+ return;
403
+ }
404
+
396
405
  // Check if method already exists
397
406
  if (content.includes(methodName)) {
398
407
  return; // Method already exists
@@ -566,10 +575,10 @@ async function updateDomainEventHandler(projectDir, packagePath, context) {
566
575
  return false;
567
576
  }
568
577
 
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)
578
+ // Compute domain event name by stripping 'IntegrationEvent' suffix from eventClassName
579
+ // e.g. OrderPlacedIntegrationEvent → OrderPlaced
580
+ const domainEventName = context.eventClassName.endsWith('IntegrationEvent')
581
+ ? context.eventClassName.slice(0, -'IntegrationEvent'.length)
573
582
  : context.eventClassName;
574
583
 
575
584
  // Guard: the TODO comment must exist (generated by g entities)
@@ -606,8 +615,10 @@ async function updateDomainEventHandler(projectDir, packagePath, context) {
606
615
  }
607
616
 
608
617
  // 4. Render the mapping call and replace the TODO block
618
+ // Derive aggregateName from the handler file name (e.g. BikeDomainEventHandler.java → Bike)
619
+ const aggregateName = handlerFile.replace('DomainEventHandler.java', '');
609
620
  const templatePath = path.join(__dirname, '..', '..', 'templates', 'kafka-event', 'DomainEventHandlerMethod.ejs');
610
- const mappingLine = await renderTemplate(templatePath, { ...context, domainEventFields: context.eventFields });
621
+ const mappingLine = await renderTemplate(templatePath, { ...context, domainEventFields: context.eventFields, aggregateName });
611
622
 
612
623
  const todoRegex = new RegExp(
613
624
  `([ \\t]*\/\/ TODO: handle ${domainEventName}[^\\n]*\\n)(?:[ \\t]*\/\/[^\\n]*\\n)*`
@@ -639,4 +650,80 @@ function injectImportIntoFile(content, importStatement) {
639
650
  }
640
651
  }
641
652
 
653
+ /**
654
+ * Returns the name of the installed message broker ('kafka' | 'rabbitmq') or null.
655
+ * Extensibility: add 'rabbitmq' support here when `eva add rabbitmq-client` is implemented.
656
+ * @param {import('../utils/config-manager')} configManager
657
+ * @returns {Promise<'kafka'|'rabbitmq'|null>}
658
+ */
659
+ async function getInstalledBroker(configManager) {
660
+ if (await configManager.featureExists('kafka')) return 'kafka';
661
+ if (await configManager.featureExists('rabbitmq')) return 'rabbitmq';
662
+ return null;
663
+ }
664
+
665
+ /**
666
+ * Strip the conventional Java 'Event' suffix from an event class name
667
+ * before deriving the Kafka topic name.
668
+ * ProductPublishedEvent → ProductPublished → PRODUCT_PUBLISHED
669
+ *
670
+ * If the event has an explicit `topic:` property in domain.yaml, that takes
671
+ * precedence and this function is not called.
672
+ */
673
+ function stripEventSuffix(name) {
674
+ return name.endsWith('Event') ? name.slice(0, -'Event'.length) : name;
675
+ }
676
+
677
+ /**
678
+ * Build a Kafka event generation context for a given domain event.
679
+ * Intended for use by `generate-entities.js` to auto-wire broker integration events
680
+ * without requiring a separate `eva g kafka-event` invocation.
681
+ *
682
+ * @param {string} packageName
683
+ * @param {string} moduleName
684
+ * @param {{ name: string, topic?: string, fields: Array }} domainEvent - Domain event from parsed domain.yaml
685
+ * @param {{ partitions?: number, replicas?: number }} [options]
686
+ * @returns {Object} context ready for generateSingleKafkaEvent()
687
+ */
688
+ function buildKafkaEventContext(packageName, moduleName, domainEvent, { partitions = 3, replicas = 1 } = {}) {
689
+ const normalizedName = toPascalCase(domainEvent.name);
690
+ const integrationEventClassName = normalizedName.endsWith('IntegrationEvent')
691
+ ? normalizedName
692
+ : `${normalizedName}IntegrationEvent`;
693
+ // If an explicit topic is declared in domain.yaml, use it as source of truth.
694
+ // Otherwise strip the 'Event' suffix before deriving snake/kebab names so that
695
+ // ProductPublishedEvent → PRODUCT_PUBLISHED (not PRODUCT_PUBLISHED_EVENT).
696
+ const topicBase = domainEvent.topic
697
+ ? domainEvent.topic.trim().toUpperCase().replace(/-/g, '_')
698
+ : toSnakeCase(stripEventSuffix(domainEvent.name)).toUpperCase();
699
+ const topicNameSnake = topicBase;
700
+ const topicNameKebab = topicBase.toLowerCase().replace(/_/g, '-');
701
+ const topicNameCamel = toCamelCase(topicNameKebab);
702
+ const topicSpringProperty = `\${topics.${topicNameKebab}}`;
703
+ return {
704
+ packageName,
705
+ moduleName,
706
+ modulePascalCase: toPascalCase(moduleName),
707
+ moduleCamelCase: toCamelCase(moduleName),
708
+ kafkaMessageBrokerClassName: `${toPascalCase(moduleName)}KafkaMessageBroker`,
709
+ eventClassName: integrationEventClassName,
710
+ topicNameSnake,
711
+ topicNameKebab,
712
+ topicNameCamel,
713
+ topicPropertyKey: topicNameKebab,
714
+ topicPropertyValue: topicNameSnake,
715
+ topicSpringProperty,
716
+ partitions,
717
+ replicas,
718
+ eventFields: domainEvent.fields || null
719
+ };
720
+ }
721
+
642
722
  module.exports = generateKafkaEventCommand;
723
+ module.exports.generateSingleKafkaEvent = generateSingleKafkaEvent;
724
+ module.exports.buildKafkaEventContext = buildKafkaEventContext;
725
+ module.exports.getInstalledBroker = getInstalledBroker;
726
+ module.exports.updateKafkaYml = updateKafkaYml;
727
+ module.exports.generateEventRecord = generateEventRecord;
728
+ module.exports.createOrUpdateMessageBroker = createOrUpdateMessageBroker;
729
+ module.exports.updateDomainEventHandler = updateDomainEventHandler;
@@ -8,7 +8,7 @@ class BaseGenerator {
8
8
  constructor(context) {
9
9
  this.context = context;
10
10
  this.templatesDir = path.join(__dirname, '../../templates/base');
11
- this.projectDir = path.join(process.cwd(), context.artifactId);
11
+ this.projectDir = path.join(process.cwd(), context.projectName);
12
12
  }
13
13
 
14
14
  async generate() {
@@ -63,6 +63,7 @@ class BaseGenerator {
63
63
  await fs.ensureDir(resources);
64
64
  await fs.ensureDir(path.join(resources, 'static'));
65
65
  await fs.ensureDir(path.join(resources, 'templates'));
66
+ await fs.ensureDir(path.join(this.projectDir, 'system'));
66
67
  }
67
68
 
68
69
  async generateApplication() {
@@ -130,6 +131,12 @@ class BaseGenerator {
130
131
  path.join(this.projectDir, 'README.md'));
131
132
  await this.generateFile('root/AGENTS.md.ejs',
132
133
  path.join(this.projectDir, 'AGENTS.md'));
134
+ await this.generateFile('root/system.yaml.ejs',
135
+ path.join(this.projectDir, 'system.yaml'));
136
+ await this.generateFile('root/skill-build-system-yaml.ejs',
137
+ path.join(this.projectDir, '.agents', 'skills', 'build-system-yaml', 'SKILL.md'));
138
+ await this.generateFile('root/skill-build-domain-yaml-references-generate-entities.md.ejs',
139
+ path.join(this.projectDir, '.agents', 'skills', 'build-system-yaml', 'references', 'GENERATE_ENTITIES.md'));
133
140
 
134
141
  if (this.context.features.includeDocker) {
135
142
  await this.generateFile('docker/docker-compose.yaml.ejs',
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs-extra');
5
+ const chalk = require('chalk');
6
+ const crypto = require('crypto');
7
+
8
+ const { parseDomainYaml } = require('../utils/yaml-to-entity');
9
+ const { renderTemplate } = require('../utils/template-engine');
10
+ const { toKebabCase, toCamelCase, toPackagePath } = require('../utils/naming');
11
+ const { initSeed, generateFakeValue, generateFakeBody, generateFakeId } = require('../utils/fake-data');
12
+
13
+ const TEMPLATES_DIR = path.join(__dirname, '../../templates/postman');
14
+
15
+ // Audit / internal fields excluded from command bodies
16
+ const EXCLUDED_FIELDS = new Set([
17
+ 'id', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', 'deletedAt',
18
+ ]);
19
+
20
+ /**
21
+ * Determine which aggregate an operation belongs to based on use case naming.
22
+ * E.g. "CreateProduct" → "Product", "FindAllCategorys" → "Category".
23
+ *
24
+ * @param {string} useCase - e.g. "CreateProduct"
25
+ * @param {Array} aggregates - parsed aggregates with `.name`
26
+ * @returns {string|null} - aggregate name or null
27
+ */
28
+ function resolveOwnerAggregate(useCase, aggregates) {
29
+ const ucLower = useCase.toLowerCase();
30
+ // Sort longest name first so "UserProfile" matches before "User"
31
+ const sorted = [...aggregates].sort((a, b) => b.name.length - a.name.length);
32
+ for (const agg of sorted) {
33
+ if (ucLower.includes(agg.name.toLowerCase())) return agg.name;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Generate a unified Postman collection covering every module in the system.
40
+ *
41
+ * @param {Object} opts
42
+ * @param {string} opts.projectDir - Absolute path to the project root
43
+ * @param {string} opts.systemDir - Absolute path to the system/ directory
44
+ * @param {string} opts.packageName - Full Java package name (e.g. "com.example.myapp")
45
+ * @param {Object} opts.systemConfig - Parsed system.yaml object
46
+ * @param {Object} opts.projectConfig - Parsed .eva4j.json project config
47
+ * @returns {Promise<string|null>} - Path to the generated file, or null on error
48
+ */
49
+ async function generateUnifiedPostmanCollection({
50
+ projectDir,
51
+ systemDir,
52
+ packageName,
53
+ systemConfig,
54
+ projectConfig,
55
+ }) {
56
+ const systemName = systemConfig.system?.name || projectConfig.projectName || projectConfig.artifactId || 'eva4j-app';
57
+ const port = projectConfig.server?.port || 8040;
58
+ const modules = systemConfig.modules || [];
59
+
60
+ if (!modules.length) return null;
61
+
62
+ // Seed faker for deterministic output
63
+ initSeed(42);
64
+
65
+ // ── Collect module contexts ───────────────────────────────────────────────
66
+ const moduleContexts = [];
67
+
68
+ for (const mod of modules) {
69
+ const yamlPath = path.join(systemDir, `${mod.name}.yaml`);
70
+ if (!(await fs.pathExists(yamlPath))) continue;
71
+
72
+ let parsed;
73
+ try {
74
+ parsed = await parseDomainYaml(yamlPath, packageName, mod.name);
75
+ } catch (err) {
76
+ console.log(chalk.yellow(` ⚠️ Could not parse ${mod.name}.yaml for Postman: ${err.message}`));
77
+ continue;
78
+ }
79
+
80
+ const { aggregates, allEnums, endpoints } = parsed;
81
+ const aggregateContexts = [];
82
+
83
+ for (const agg of aggregates) {
84
+ const rootEntity = agg.rootEntity;
85
+ const idField = rootEntity.fields.find(f => f.name === 'id');
86
+ const idType = idField ? idField.javaType : 'String';
87
+ const exampleId = generateFakeId(idType);
88
+ const trackUser = rootEntity.audit?.trackUser === true;
89
+ const valueObjects = agg.valueObjects || [];
90
+
91
+ // Command fields: exclude id, audit, readOnly, deletedAt
92
+ const commandFields = rootEntity.fields.filter(
93
+ f => !EXCLUDED_FIELDS.has(f.name) && !f.readOnly
94
+ );
95
+
96
+ // Build fake body for create/update
97
+ const defaultBody = generateFakeBody(commandFields, [], allEnums, valueObjects);
98
+
99
+ if (endpoints && endpoints.versions && endpoints.versions.length > 0) {
100
+ // ── Endpoint-driven ───────────────────────────────────────────────
101
+ const operations = [];
102
+ const bodies = {};
103
+
104
+ for (const version of endpoints.versions) {
105
+ for (const op of version.operations) {
106
+ // Classify operation → which aggregate it belongs to
107
+ const owner = resolveOwnerAggregate(op.useCase, aggregates);
108
+ if (owner && owner !== agg.name) continue;
109
+ // If no aggregate could be resolved, assign to the first aggregate
110
+ if (!owner && agg !== aggregates[0]) continue;
111
+
112
+ const basePath = endpoints.basePath || '/';
113
+ operations.push({
114
+ useCase: op.useCase,
115
+ method: op.method,
116
+ path: op.path || '/',
117
+ basePath,
118
+ version: version.version,
119
+ });
120
+
121
+ // Generate body for write operations
122
+ if (op.method === 'POST' || op.method === 'PUT' || op.method === 'PATCH') {
123
+ // Re-seed per operation so bodies vary
124
+ bodies[op.useCase] = generateFakeBody(commandFields, [], allEnums, valueObjects);
125
+ }
126
+ }
127
+ }
128
+
129
+ aggregateContexts.push({
130
+ name: agg.name,
131
+ trackUser,
132
+ idType,
133
+ exampleId,
134
+ resourceNameKebab: toKebabCase(agg.name),
135
+ operations,
136
+ defaultCrud: false,
137
+ bodies,
138
+ });
139
+ } else {
140
+ // ── Default CRUD (no endpoints section) ──────────────────────────
141
+ aggregateContexts.push({
142
+ name: agg.name,
143
+ trackUser,
144
+ idType,
145
+ exampleId,
146
+ resourceNameKebab: toKebabCase(agg.name),
147
+ operations: null,
148
+ defaultCrud: true,
149
+ bodies: { default: defaultBody },
150
+ });
151
+ }
152
+ }
153
+
154
+ if (aggregateContexts.length > 0) {
155
+ moduleContexts.push({
156
+ name: mod.name,
157
+ aggregates: aggregateContexts,
158
+ });
159
+ }
160
+ }
161
+
162
+ if (!moduleContexts.length) return null;
163
+
164
+ // ── Render template ─────────────────────────────────────────────────────
165
+ const templatePath = path.join(TEMPLATES_DIR, 'UnifiedCollection.json.ejs');
166
+ const collectionId = crypto.randomUUID();
167
+
168
+ const context = {
169
+ systemName,
170
+ collectionId,
171
+ port,
172
+ modules: moduleContexts,
173
+ };
174
+
175
+ const content = await renderTemplate(templatePath, context);
176
+
177
+ // ── Write output ────────────────────────────────────────────────────────
178
+ const outputDir = path.join(projectDir, 'postman');
179
+ await fs.ensureDir(outputDir);
180
+
181
+ const outputFileName = `${toKebabCase(systemName)}-Postman-Collection.json`;
182
+ const outputPath = path.join(outputDir, outputFileName);
183
+ await fs.writeFile(outputPath, content, 'utf-8');
184
+
185
+ return outputPath;
186
+ }
187
+
188
+ module.exports = { generateUnifiedPostmanCollection };
@@ -194,6 +194,12 @@ class SharedGenerator {
194
194
  path.join(handlerExceptionPath, 'HandlerExceptions.java'));
195
195
  }
196
196
 
197
+ async generateMockEvent(basePath) {
198
+ const destPath = path.join(basePath, 'infrastructure', 'mockEvent', 'MockEvent.java');
199
+ const templatePath = path.join(__dirname, '../../templates/mock/MockEvent.java.ejs');
200
+ await renderAndWrite(templatePath, destPath, this.context, { overwrite: false });
201
+ }
202
+
197
203
  async generateConfigurations(basePath) {
198
204
  const configurationsPath = path.join(basePath, 'infrastructure', 'configurations');
199
205
 
@@ -209,6 +215,10 @@ class SharedGenerator {
209
215
  await this.generateFile('configurations/swaggerConfig/SwaggerConfig.java.ejs',
210
216
  path.join(configurationsPath, 'swaggerConfig', 'SwaggerConfig.java'));
211
217
 
218
+ // Event publication schema fix (Spring Modulith varchar(255) → TEXT)
219
+ await this.generateFile('configurations/eventPublicationConfig/EventPublicationSchemaConfig.java.ejs',
220
+ path.join(configurationsPath, 'eventPublicationConfig', 'EventPublicationSchemaConfig.java'));
221
+
212
222
  // UseCase config
213
223
  await this.generateFile('configurations/useCaseConfig/UseCaseAutoRegister.java.ejs',
214
224
  path.join(configurationsPath, 'useCaseConfig', 'UseCaseAutoRegister.java'));
@@ -151,6 +151,60 @@ class ConfigManager {
151
151
 
152
152
  return config;
153
153
  }
154
+
155
+ /**
156
+ * Persist mock backup data into .eva4j.json so it survives process restarts.
157
+ * @param {Object} backups - Map of { key: { path, content } }
158
+ */
159
+ async saveMockBackup(backups, opts = {}) {
160
+ const config = await this.loadProjectConfig();
161
+ if (!config) throw new Error('Project configuration not found.');
162
+
163
+ config._mockBackup = {};
164
+ for (const [key, { path: filePath, content }] of Object.entries(backups)) {
165
+ config._mockBackup[key] = { path: filePath, content };
166
+ }
167
+ config._mockActive = true;
168
+ if (opts.onlyBroker) {
169
+ config._mockOnlyBroker = true;
170
+ }
171
+ config.updatedAt = new Date().toISOString();
172
+ await fs.writeJson(this.configFile, config, { spaces: 2 });
173
+ }
174
+
175
+ /**
176
+ * Read and clear mock backup from .eva4j.json.
177
+ * @returns {Object|null} backups map or null if no mock backup stored
178
+ */
179
+ async popMockBackup() {
180
+ const config = await this.loadProjectConfig();
181
+ if (!config || !config._mockBackup) return null;
182
+
183
+ const backups = config._mockBackup;
184
+ delete config._mockBackup;
185
+ delete config._mockActive;
186
+ delete config._mockOnlyBroker;
187
+ config.updatedAt = new Date().toISOString();
188
+ await fs.writeJson(this.configFile, config, { spaces: 2 });
189
+ return backups;
190
+ }
191
+
192
+ /**
193
+ * Returns true if a mock backup is currently stored in .eva4j.json.
194
+ */
195
+ async hasMockBackup() {
196
+ const config = await this.loadProjectConfig();
197
+ return !!(config && config._mockActive);
198
+ }
199
+
200
+ /**
201
+ * Returns true if the active mock was started with --only-broker
202
+ * (i.e. only the broker was swapped, DB config was kept unchanged).
203
+ */
204
+ async hasMockOnlyBroker() {
205
+ const config = await this.loadProjectConfig();
206
+ return !!(config && config._mockOnlyBroker);
207
+ }
154
208
  }
155
209
 
156
210
  module.exports = ConfigManager;
@@ -30,6 +30,7 @@ function buildBaseContext(answers) {
30
30
  dependencyManagementVersion: defaults.dependencyManagementVersion,
31
31
  springModulithVersion: defaults.springModulithVersion,
32
32
  springCloudVersion: defaults.springCloudVersion,
33
+ springdocVersion: defaults.springdocVersion,
33
34
  gradleVersion: defaults.gradleVersion,
34
35
  dependencies: answers.dependencies || [],
35
36
  author: answers.author || 'Generated by eva4j',
@@ -0,0 +1,192 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Generates Mermaid classDiagram text per module from parsed domain.yaml objects.
5
+ *
6
+ * @param {Object} domainConfigs Plain object { [moduleName]: parsedDomainYaml }
7
+ * @returns {{ [moduleName]: string }} Map of module → Mermaid diagram text (empty string if no aggregates)
8
+ */
9
+ function generateDomainDiagrams(domainConfigs) {
10
+ const result = {};
11
+ for (const [moduleName, config] of Object.entries(domainConfigs)) {
12
+ result[moduleName] = generateModuleDiagram(moduleName, config);
13
+ }
14
+ return result;
15
+ }
16
+
17
+ // ── Helpers ───────────────────────────────────────────────────────────────────
18
+
19
+ const AUDIT_FIELDS = new Set(['createdAt', 'updatedAt', 'createdBy', 'updatedBy']);
20
+
21
+ /** Normalize to PascalCase (capitalize first letter only). */
22
+ function toPascal(name) {
23
+ if (!name) return name;
24
+ return name.charAt(0).toUpperCase() + name.slice(1);
25
+ }
26
+
27
+ function fieldPrefix(field) {
28
+ if (field.hidden) return '-';
29
+ if (field.readOnly) return '~';
30
+ return '+';
31
+ }
32
+
33
+ // ── Per-module diagram builder ────────────────────────────────────────────────
34
+
35
+ function generateModuleDiagram(moduleName, config) {
36
+ const aggregates = config.aggregates || [];
37
+ if (aggregates.length === 0) return '';
38
+
39
+ // Flat diagram — namespace blocks are avoided because Mermaid's layout engine
40
+ // can place inner classes outside the visual boundary, causing overflow.
41
+ // Aggregates are separated by %% comment dividers instead.
42
+ const lines = ['classDiagram'];
43
+ const relationships = []; // Collected per-aggregate, emitted at the end
44
+ const notes = []; // `note for ClassName "..."` — for cross-aggregate refs
45
+
46
+ // Build a set of all entity/VO/enum PascalCase class names defined in this module
47
+ // so we can distinguish intra-module from cross-aggregate relationships.
48
+ const localClasses = new Set();
49
+ for (const aggregate of aggregates) {
50
+ for (const e of aggregate.entities || []) localClasses.add(toPascal(e.name));
51
+ for (const v of aggregate.valueObjects || []) localClasses.add(toPascal(v.name));
52
+ for (const en of aggregate.enums || []) localClasses.add(toPascal(en.name));
53
+ }
54
+
55
+ for (const aggregate of aggregates) {
56
+ const entities = aggregate.entities || [];
57
+ const valueObjects = aggregate.valueObjects || [];
58
+ const enums = aggregate.enums || [];
59
+
60
+ lines.push('');
61
+ lines.push(` %% ── Aggregate: ${aggregate.name} ─────────────────────`);
62
+
63
+ // ── Entities ─────────────────────────────────────────────────────────────
64
+ for (const entity of entities) {
65
+ const className = toPascal(entity.name);
66
+ lines.push(` class ${className} {`);
67
+ lines.push(` ${entity.isRoot ? '<<aggregate root>>' : '<<entity>>'}`);
68
+
69
+ for (const field of entity.fields || []) {
70
+ if (AUDIT_FIELDS.has(field.name)) continue;
71
+ lines.push(` ${fieldPrefix(field)}${field.type} ${field.name}`);
72
+ }
73
+
74
+ if (entity.audit?.enabled) {
75
+ lines.push(` +LocalDateTime createdAt`);
76
+ lines.push(` +LocalDateTime updatedAt`);
77
+ }
78
+ if (entity.audit?.trackUser) {
79
+ lines.push(` +String createdBy`);
80
+ lines.push(` +String updatedBy`);
81
+ }
82
+
83
+ lines.push(` }`);
84
+ }
85
+
86
+ // ── Value objects ─────────────────────────────────────────────────────────
87
+ for (const vo of valueObjects) {
88
+ const className = toPascal(vo.name);
89
+ lines.push(` class ${className} {`);
90
+ lines.push(` <<value object>>`);
91
+ for (const field of vo.fields || []) {
92
+ lines.push(` +${field.type} ${field.name}`);
93
+ }
94
+ lines.push(` }`);
95
+ }
96
+
97
+ // ── Enums ─────────────────────────────────────────────────────────────────
98
+ for (const en of enums) {
99
+ const className = toPascal(en.name);
100
+ lines.push(` class ${className} {`);
101
+ lines.push(` <<enumeration>>`);
102
+ for (const val of en.values || []) {
103
+ lines.push(` ${val}`);
104
+ }
105
+ lines.push(` }`);
106
+ }
107
+
108
+ // ── Collect relationships ─────────────────────────────────────────────────
109
+ const voNames = new Set(valueObjects.map((v) => toPascal(v.name)));
110
+ const enumNames = new Set(enums.map((e) => toPascal(e.name)));
111
+
112
+ for (const entity of entities) {
113
+ const srcClass = toPascal(entity.name);
114
+
115
+ // Structural JPA relationships — only emit if target is a local class
116
+ for (const rel of entity.relationships || []) {
117
+ const target = toPascal(rel.target || rel.targetEntity || '');
118
+ if (!target) continue;
119
+ if (!localClasses.has(target)) continue; // skip cross-aggregate targets
120
+ const label = rel.mappedBy ? ` : ${rel.mappedBy}` : '';
121
+ switch (rel.type) {
122
+ case 'OneToMany':
123
+ relationships.push(` ${srcClass} "1" --o "*" ${target}${label}`);
124
+ break;
125
+ case 'OneToOne':
126
+ relationships.push(` ${srcClass} "1" --o "1" ${target}${label}`);
127
+ break;
128
+ case 'ManyToOne':
129
+ relationships.push(` ${srcClass} "*" --> "1" ${target}`);
130
+ break;
131
+ case 'ManyToMany':
132
+ relationships.push(` ${srcClass} "*" --> "*" ${target}`);
133
+ break;
134
+ default:
135
+ relationships.push(` ${srcClass} --> ${target}${label}`);
136
+ }
137
+ }
138
+
139
+ // Field-level type references within the same module (deduplicated)
140
+ const seenEdges = new Set();
141
+ const crossRefs = []; // cross-aggregate reference fields for this entity
142
+
143
+ for (const field of entity.fields || []) {
144
+ if (AUDIT_FIELDS.has(field.name)) continue;
145
+
146
+ const fieldType = toPascal(field.type);
147
+
148
+ if (enumNames.has(fieldType)) {
149
+ const key = `${srcClass}-->${fieldType}`;
150
+ if (!seenEdges.has(key)) {
151
+ relationships.push(` ${srcClass} --> ${fieldType} : ${field.name}`);
152
+ seenEdges.add(key);
153
+ }
154
+ } else if (voNames.has(fieldType)) {
155
+ const key = `${srcClass}*--${fieldType}`;
156
+ if (!seenEdges.has(key)) {
157
+ relationships.push(` ${srcClass} *-- ${fieldType} : ${field.name}`);
158
+ seenEdges.add(key);
159
+ }
160
+ } else if (field.reference) {
161
+ // Cross-aggregate reference: render as a note instead of an arrow
162
+ // to avoid undefined ghost nodes appearing outside the diagram.
163
+ const refModule = field.reference.module;
164
+ const refAggregate = field.reference.aggregate;
165
+ const label =
166
+ refModule && refModule !== moduleName
167
+ ? `${field.name} → ${refAggregate} (${refModule})`
168
+ : `${field.name} → ${refAggregate}`;
169
+ crossRefs.push(label);
170
+ }
171
+ }
172
+
173
+ if (crossRefs.length > 0) {
174
+ notes.push(` note for ${srcClass} "refs: ${crossRefs.join(', ')}"`);
175
+ }
176
+ }
177
+ }
178
+
179
+ if (relationships.length > 0) {
180
+ lines.push('');
181
+ lines.push(...relationships);
182
+ }
183
+
184
+ if (notes.length > 0) {
185
+ lines.push('');
186
+ lines.push(...notes);
187
+ }
188
+
189
+ return lines.join('\n');
190
+ }
191
+
192
+ module.exports = { generateDomainDiagrams };