eva4j 1.0.16 → 1.0.17

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 (43) hide show
  1. package/AGENTS.md +218 -5
  2. package/DOMAIN_YAML_GUIDE.md +185 -2
  3. package/FUTURE_FEATURES.md +33 -52
  4. package/docs/commands/EVALUATE_SYSTEM.md +18 -2
  5. package/examples/domain-events.yaml +26 -0
  6. package/examples/domain-read-models.yaml +113 -0
  7. package/package.json +1 -1
  8. package/read-model-spec.md +664 -0
  9. package/src/agents/design-reviewer.agent.md +3 -0
  10. package/src/commands/generate-entities.js +254 -10
  11. package/src/commands/generate-http-exchange.js +3 -0
  12. package/src/commands/generate-kafka-event.js +3 -0
  13. package/src/commands/generate-kafka-listener.js +3 -0
  14. package/src/commands/generate-record.js +2 -2
  15. package/src/commands/generate-resource.js +4 -1
  16. package/src/commands/generate-temporal-activity.js +4 -1
  17. package/src/commands/generate-temporal-flow.js +4 -1
  18. package/src/commands/generate-usecase.js +4 -1
  19. package/src/skills/build-system-yaml/SKILL.md +122 -1
  20. package/src/skills/build-system-yaml/references/domain-yaml-spec.md +205 -24
  21. package/src/skills/build-system-yaml/references/module-spec.md +33 -1
  22. package/src/skills/build-system-yaml/references/system-yaml-spec.md +36 -0
  23. package/src/utils/config-manager.js +4 -2
  24. package/src/utils/domain-validator.js +230 -14
  25. package/src/utils/naming.js +10 -0
  26. package/src/utils/validator.js +3 -1
  27. package/src/utils/yaml-to-entity.js +272 -3
  28. package/templates/aggregate/AggregateRepository.java.ejs +4 -0
  29. package/templates/aggregate/AggregateRepositoryImpl.java.ejs +8 -0
  30. package/templates/aggregate/AggregateRoot.java.ejs +38 -4
  31. package/templates/aggregate/JpaAggregateRoot.java.ejs +2 -2
  32. package/templates/crud/DeleteCommandHandler.java.ejs +19 -1
  33. package/templates/crud/UpdateCommandHandler.java.ejs +53 -2
  34. package/templates/read-model/ReadModelDomain.java.ejs +46 -0
  35. package/templates/read-model/ReadModelJpa.java.ejs +58 -0
  36. package/templates/read-model/ReadModelJpaRepository.java.ejs +11 -0
  37. package/templates/read-model/ReadModelKafkaListener.java.ejs +64 -0
  38. package/templates/read-model/ReadModelRepository.java.ejs +42 -0
  39. package/templates/read-model/ReadModelRepositoryImpl.java.ejs +81 -0
  40. package/templates/read-model/ReadModelSyncHandler.java.ejs +52 -0
  41. package/test-c2010.js +49 -0
  42. package/test-update-compat.js +109 -0
  43. package/test-update-lifecycle.js +121 -0
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { pluralizeWord } = require('./naming');
3
+ const { pluralizeWord, singularizeWord } = require('./naming');
4
4
 
5
5
  /**
6
6
  * Domain-level validator for eva evaluate system --domain
@@ -119,7 +119,10 @@ function buildSystemAsyncMap(systemConfig) {
119
119
  map[ev.event] = {
120
120
  producer: ev.producer,
121
121
  topic: ev.topic,
122
- consumers: (ev.consumers || []).map((c) => (typeof c === 'string' ? c : c.module)),
122
+ consumers: (ev.consumers || []).map((c) => {
123
+ if (typeof c === 'string') return { module: c, useCase: undefined, readModel: undefined };
124
+ return { module: c.module, useCase: c.useCase, readModel: c.readModel };
125
+ }),
123
126
  };
124
127
  }
125
128
  return map;
@@ -213,8 +216,9 @@ function runC1(domainConfigs, systemConfig) {
213
216
  'C1-002': { label: 'Listener referencia evento que ningún módulo produce', severity: 'ok', findings: [] },
214
217
  'C1-003': { label: 'Campo en listener.fields no existe en el evento del productor', severity: 'ok', findings: [] },
215
218
  'C1-004': { label: 'Campo existe pero con tipo incompatible productor/consumidor', severity: 'ok', findings: [] },
216
- 'C1-005': { label: 'system.yaml registra consumidor pero módulo no tiene listener declarado', severity: 'ok', findings: [] },
219
+ 'C1-005': { label: 'system.yaml registra consumidor pero módulo no tiene listener o readModel.syncedBy declarado', severity: 'ok', findings: [] },
217
220
  'C1-006': { label: 'Listener declara producer: incorrecto', severity: 'ok', findings: [] },
221
+ 'C1-007': { label: 'Campo de readModel no cubierto por eventos UPSERT del productor', severity: 'ok', findings: [] },
218
222
  };
219
223
 
220
224
  // C1-001: produced event in domain but zero consumers in system.yaml
@@ -277,20 +281,38 @@ function runC1(domainConfigs, systemConfig) {
277
281
  }
278
282
  }
279
283
 
280
- // C1-005: system.yaml consumer present but module has no listener
284
+ // C1-005: system.yaml consumer present but module has no listener or readModel.syncedBy
281
285
  for (const [eventName, sysEntry] of Object.entries(systemAsyncMap)) {
282
- for (const consumerModule of sysEntry.consumers) {
283
- const consumerConfig = domainConfigs[consumerModule];
286
+ for (const consumer of sysEntry.consumers) {
287
+ const consumerConfig = domainConfigs[consumer.module];
284
288
  if (!consumerConfig) continue; // module domain.yaml not loaded — skip
285
- const hasListener = (consumerConfig.listeners || []).some((l) => l.event === eventName);
286
- if (!hasListener) {
287
- checks['C1-005'].findings.push(
288
- finding(
289
- consumerModule,
290
- `system.yaml registra '${consumerModule}' como consumidor de '${eventName}' pero el módulo no tiene listener declarado`,
291
- `Evento producido por: ${sysEntry.producer}`
292
- )
289
+
290
+ if (consumer.readModel) {
291
+ // readModel consumer → check readModels[].syncedBy[]
292
+ const hasSync = (consumerConfig.readModels || []).some((rm) =>
293
+ (rm.syncedBy || []).some((s) => s.event === eventName)
293
294
  );
295
+ if (!hasSync) {
296
+ checks['C1-005'].findings.push(
297
+ finding(
298
+ consumer.module,
299
+ `system.yaml registra '${consumer.module}' como consumidor readModel de '${eventName}' pero el módulo no tiene readModels[].syncedBy con ese evento`,
300
+ `Evento producido por: ${sysEntry.producer}, readModel esperado: ${consumer.readModel}`
301
+ )
302
+ );
303
+ }
304
+ } else {
305
+ // useCase consumer → check listeners[]
306
+ const hasListener = (consumerConfig.listeners || []).some((l) => l.event === eventName);
307
+ if (!hasListener) {
308
+ checks['C1-005'].findings.push(
309
+ finding(
310
+ consumer.module,
311
+ `system.yaml registra '${consumer.module}' como consumidor de '${eventName}' pero el módulo no tiene listener declarado`,
312
+ `Evento producido por: ${sysEntry.producer}`
313
+ )
314
+ );
315
+ }
294
316
  }
295
317
  }
296
318
  }
@@ -312,6 +334,35 @@ function runC1(domainConfigs, systemConfig) {
312
334
  }
313
335
  }
314
336
 
337
+ // C1-007: readModel field not covered by any UPSERT event from producer (RM-008)
338
+ for (const [moduleName, config] of Object.entries(domainConfigs)) {
339
+ for (const rm of config.readModels || []) {
340
+ // Collect field names from all UPSERT syncedBy events
341
+ const upsertEventFields = new Set();
342
+ for (const sync of rm.syncedBy || []) {
343
+ if ((sync.action || '').toUpperCase() !== 'UPSERT') continue;
344
+ const producerInfo = producedEvents[sync.event];
345
+ if (!producerInfo) continue; // caught by other checks
346
+ for (const f of producerInfo.fields) {
347
+ upsertEventFields.add(f.name);
348
+ }
349
+ }
350
+ // Check each readModel field (except 'id') is covered
351
+ for (const rmField of rm.fields || []) {
352
+ if (rmField.name === 'id') continue; // mapped to {entityName}Id in events
353
+ if (!upsertEventFields.has(rmField.name)) {
354
+ checks['C1-007'].findings.push(
355
+ finding(
356
+ moduleName,
357
+ `ReadModel '${rm.name}' tiene campo '${rmField.name}' que no aparece en ningún evento UPSERT de syncedBy`,
358
+ `Source: ${rm.source ? rm.source.module : '?'}. El campo siempre será null — agregar a los events del productor o quitar del readModel`
359
+ )
360
+ );
361
+ }
362
+ }
363
+ }
364
+ }
365
+
315
366
  // Assign severities
316
367
  setDefaultSeverities(checks, {
317
368
  'C1-001': 'info',
@@ -320,6 +371,7 @@ function runC1(domainConfigs, systemConfig) {
320
371
  'C1-004': 'error',
321
372
  'C1-005': 'error',
322
373
  'C1-006': 'error',
374
+ 'C1-007': 'warning',
323
375
  });
324
376
 
325
377
  return checks;
@@ -336,6 +388,10 @@ function runC2(domainConfigs, systemConfig) {
336
388
  'C2-005': { label: 'Transición de estado sin Domain Event asociado (sin trigger)', severity: 'ok', findings: [] },
337
389
  'C2-006': { label: 'Colisión de nombre de useCase entre endpoints y listeners', severity: 'ok', findings: [] },
338
390
  'C2-007': { label: 'UseCase FindAll con nombre de agregado sin pluralizar correctamente', severity: 'ok', findings: [] },
391
+ 'C2-008': { label: 'Evento con valor de lifecycle inválido', severity: 'ok', findings: [] },
392
+ 'C2-009': { label: 'Evento lifecycle incompatible con configuración de entidad', severity: 'ok', findings: [] },
393
+ 'C2-010': { label: 'Campo de lifecycle event no existe en la entidad raíz', severity: 'ok', findings: [] },
394
+ 'C2-011': { label: 'Endpoint useCase no se resuelve a ningún agregado del módulo', severity: 'ok', findings: [] },
339
395
  };
340
396
 
341
397
  for (const [moduleName, config] of Object.entries(domainConfigs)) {
@@ -405,6 +461,7 @@ function runC2(domainConfigs, systemConfig) {
405
461
  }
406
462
 
407
463
  // C2-004: event trigger references a method that does not exist in any transition
464
+ // Skipped for events that use lifecycle: instead of triggers:
408
465
  const allTransitionMethods = new Set();
409
466
  for (const agg of config.aggregates || []) {
410
467
  for (const en of agg.enums || []) {
@@ -415,6 +472,7 @@ function runC2(domainConfigs, systemConfig) {
415
472
  }
416
473
  for (const agg of config.aggregates || []) {
417
474
  for (const ev of agg.events || []) {
475
+ if (ev.lifecycle) continue; // lifecycle events don't reference transition methods
418
476
  for (const trigger of ev.triggers || []) {
419
477
  if (!allTransitionMethods.has(trigger)) {
420
478
  checks['C2-004'].findings.push(
@@ -429,6 +487,74 @@ function runC2(domainConfigs, systemConfig) {
429
487
  }
430
488
  }
431
489
 
490
+ // C2-008: event lifecycle value is not one of the valid options
491
+ const validLifecycleValues = ['create', 'update', 'delete', 'softDelete'];
492
+ for (const agg of config.aggregates || []) {
493
+ for (const ev of agg.events || []) {
494
+ if (ev.lifecycle && !validLifecycleValues.includes(ev.lifecycle)) {
495
+ checks['C2-008'].findings.push(
496
+ finding(
497
+ moduleName,
498
+ `Evento '${ev.name}' tiene lifecycle: '${ev.lifecycle}' que no es un valor válido`,
499
+ `Valores válidos: ${validLifecycleValues.join(', ')}`
500
+ )
501
+ );
502
+ }
503
+ }
504
+ }
505
+
506
+ // C2-009: lifecycle value is incompatible with entity configuration
507
+ for (const agg of config.aggregates || []) {
508
+ const rootEntity = (agg.entities || []).find(e => e.isRoot);
509
+ const hasSoftDelete = rootEntity && rootEntity.hasSoftDelete;
510
+ for (const ev of agg.events || []) {
511
+ if (ev.lifecycle === 'softDelete' && !hasSoftDelete) {
512
+ checks['C2-009'].findings.push(
513
+ finding(
514
+ moduleName,
515
+ `Evento '${ev.name}' tiene lifecycle: 'softDelete' pero la entidad raíz '${rootEntity ? rootEntity.name : agg.name}' no tiene hasSoftDelete: true`,
516
+ `Agregado: ${agg.name}. Agregar hasSoftDelete: true a la entidad raíz o cambiar lifecycle a 'delete'`
517
+ )
518
+ );
519
+ }
520
+ if (ev.lifecycle === 'delete' && hasSoftDelete) {
521
+ checks['C2-009'].findings.push(
522
+ finding(
523
+ moduleName,
524
+ `Evento '${ev.name}' tiene lifecycle: 'delete' pero la entidad raíz '${rootEntity.name}' tiene hasSoftDelete: true`,
525
+ `Agregado: ${agg.name}. Usar lifecycle: 'softDelete' en su lugar o quitar hasSoftDelete`
526
+ )
527
+ );
528
+ }
529
+ }
530
+ }
531
+
532
+ // C2-010: lifecycle event field not found in root entity
533
+ for (const agg of config.aggregates || []) {
534
+ const rootEntityC10 = (agg.entities || []).find(e => e.isRoot);
535
+ if (!rootEntityC10) continue;
536
+ const entityFieldNames = new Set((rootEntityC10.fields || []).map(f => f.name));
537
+ const entityBase = rootEntityC10.name.charAt(0).toLowerCase() + rootEntityC10.name.slice(1);
538
+ for (const ev of agg.events || []) {
539
+ if (!ev.lifecycle) continue;
540
+ for (const ef of ev.fields || []) {
541
+ // Skip {entityName}Id — mapped to aggregateId in DomainEvent
542
+ if (ef.name === entityBase + 'Id') continue;
543
+ // Skip temporal auto-resolved fields (*At + LocalDateTime)
544
+ if (ef.name.endsWith('At') && ef.type === 'LocalDateTime') continue;
545
+ if (!entityFieldNames.has(ef.name)) {
546
+ checks['C2-010'].findings.push(
547
+ finding(
548
+ moduleName,
549
+ `Evento '${ev.name}' (lifecycle: ${ev.lifecycle}) tiene campo '${ef.name}' que no existe en la entidad raíz '${rootEntityC10.name}'`,
550
+ `Agregado: ${agg.name}. Quitar '${ef.name}' del evento o agregar el campo a la entidad`
551
+ )
552
+ );
553
+ }
554
+ }
555
+ }
556
+ }
557
+
432
558
  // C2-005: transition method without any associated domain event trigger
433
559
  for (const agg of config.aggregates || []) {
434
560
  for (const en of agg.enums || []) {
@@ -566,6 +692,92 @@ function runC2(domainConfigs, systemConfig) {
566
692
  }
567
693
  }
568
694
  }
695
+
696
+ // C2-011: Endpoint useCase not semantically resolvable to any aggregate
697
+ // Detects FindAll/Get use cases that won't match any aggregate via exact
698
+ // or fuzzy matching — these silently fall to scaffold with the wrong
699
+ // aggregate's ResponseDto, producing code that doesn't compile.
700
+ const allAggs = config.aggregates || [];
701
+ for (const ver of (config.endpoints && config.endpoints.versions) || []) {
702
+ for (const op of ver.operations || []) {
703
+ const uc = op.useCase || '';
704
+ let resolved = false;
705
+
706
+ for (const agg of allAggs) {
707
+ const aggName = agg.name;
708
+ const aggPlural = pluralizeWord(aggName);
709
+ // Exact standard match
710
+ if (uc === `Create${aggName}` || uc === `Update${aggName}` ||
711
+ uc === `Delete${aggName}` || uc === `Get${aggName}` ||
712
+ uc === `FindAll${aggPlural}`) {
713
+ resolved = true;
714
+ break;
715
+ }
716
+ // Fuzzy FindAll: singular of suffix is prefix of aggregate name (or vice-versa)
717
+ if (uc.startsWith('FindAll')) {
718
+ const suffix = uc.slice(7);
719
+ if (suffix) {
720
+ const singular = singularizeWord(suffix).toLowerCase();
721
+ const aggLower = aggName.toLowerCase();
722
+ if (aggLower.startsWith(singular) || singular.startsWith(aggLower)) {
723
+ resolved = true;
724
+ break;
725
+ }
726
+ }
727
+ }
728
+ // Fuzzy Get: suffix is prefix of aggregate name (or vice-versa)
729
+ if (uc.startsWith('Get') && !uc.startsWith('GetAll')) {
730
+ const suffix = uc.slice(3);
731
+ if (suffix) {
732
+ const suffixLower = suffix.toLowerCase();
733
+ const aggLower = aggName.toLowerCase();
734
+ if (aggLower.startsWith(suffixLower) || suffixLower.startsWith(aggLower)) {
735
+ resolved = true;
736
+ break;
737
+ }
738
+ }
739
+ }
740
+ // Transition match
741
+ const entities = agg.entities || [];
742
+ const rootEntity = entities.find(e => e.isRoot) || entities[0] || {};
743
+ const enums = rootEntity.enums || agg.enums || [];
744
+ for (const enumDef of enums) {
745
+ for (const tr of (enumDef.transitions || [])) {
746
+ const methodPascal = tr.method.charAt(0).toUpperCase() + tr.method.slice(1);
747
+ if (uc === `${methodPascal}${aggName}`) {
748
+ resolved = true;
749
+ }
750
+ }
751
+ }
752
+ if (resolved) break;
753
+ // SubEntity match
754
+ const rels = (rootEntity.relationships || []).filter(r => r.type === 'OneToMany' && !r.isInverse);
755
+ for (const rel of rels) {
756
+ if (uc === `Add${rel.target}` || uc === `Remove${rel.target}`) {
757
+ resolved = true;
758
+ break;
759
+ }
760
+ }
761
+ if (resolved) break;
762
+ // Substring fallback: aggregate name inside useCase
763
+ if (uc.toLowerCase().includes(aggName.toLowerCase())) {
764
+ resolved = true;
765
+ break;
766
+ }
767
+ }
768
+
769
+ if (!resolved) {
770
+ const firstAgg = allAggs.length > 0 ? allAggs[0].name : '(none)';
771
+ checks['C2-011'].findings.push(
772
+ finding(
773
+ moduleName,
774
+ `UseCase '${uc}' no se resuelve a ningún agregado del módulo — se asignará al primero ('${firstAgg}') y generará código con tipos incorrectos`,
775
+ `Versión: ${ver.version}. Considere renombrar el useCase para que contenga el nombre del agregado, o verificar que el agregado destino existe.`
776
+ )
777
+ );
778
+ }
779
+ }
780
+ }
569
781
  }
570
782
 
571
783
  setDefaultSeverities(checks, {
@@ -576,6 +788,10 @@ function runC2(domainConfigs, systemConfig) {
576
788
  'C2-005': 'info',
577
789
  'C2-006': 'error',
578
790
  'C2-007': 'error',
791
+ 'C2-008': 'error',
792
+ 'C2-009': 'warning',
793
+ 'C2-010': 'error',
794
+ 'C2-011': 'error',
579
795
  });
580
796
 
581
797
  return checks;
@@ -56,6 +56,15 @@ function pluralizeWord(word) {
56
56
  return pluralize(word);
57
57
  }
58
58
 
59
+ /**
60
+ * Singularize a word
61
+ * @param {string} word - Word to singularize
62
+ * @returns {string} Singularized word
63
+ */
64
+ function singularizeWord(word) {
65
+ return pluralize.singular(word);
66
+ }
67
+
59
68
  /**
60
69
  * Convert package name to path
61
70
  * @param {string} packageName - Package name (e.g., com.company.project)
@@ -129,6 +138,7 @@ module.exports = {
129
138
  toSnakeCase,
130
139
  toKebabCase,
131
140
  pluralizeWord,
141
+ singularizeWord,
132
142
  toPackagePath,
133
143
  getBaseEntity,
134
144
  artifactIdToPackageName,
@@ -1,5 +1,6 @@
1
1
  const fs = require('fs-extra');
2
2
  const path = require('path');
3
+ const { toCamelCase } = require('./naming');
3
4
 
4
5
  /**
5
6
  * Validate project name
@@ -107,7 +108,8 @@ async function isEva4jProject(dir) {
107
108
  * @returns {boolean} True if module exists
108
109
  */
109
110
  async function moduleExists(projectDir, packagePath, moduleName) {
110
- const modulePath = path.join(projectDir, 'src', 'main', 'java', packagePath, moduleName);
111
+ const normalized = toCamelCase(moduleName);
112
+ const modulePath = path.join(projectDir, 'src', 'main', 'java', packagePath, normalized);
111
113
  return await fs.pathExists(modulePath);
112
114
  }
113
115
 
@@ -49,12 +49,16 @@ async function parseDomainYaml(yamlPath, packageName = '', moduleName = '') {
49
49
  );
50
50
  }
51
51
 
52
+ const ports = parsePorts(domainData, moduleName);
53
+ const readModels = parseReadModels(domainData, moduleName, ports);
54
+
52
55
  return {
53
56
  aggregates,
54
57
  allEnums: extractAllEnums(domainData.aggregates),
55
58
  endpoints,
56
59
  listeners,
57
- ports: parsePorts(domainData, moduleName)
60
+ ports,
61
+ readModels
58
62
  };
59
63
  }
60
64
 
@@ -108,7 +112,8 @@ function parseAggregate(aggregateData) {
108
112
  name: eventName,
109
113
  fieldName: toCamelCase(eventName),
110
114
  fields: eventFields,
111
- triggers: event.triggers || []
115
+ triggers: event.triggers || [],
116
+ lifecycle: event.lifecycle || null
112
117
  };
113
118
  });
114
119
 
@@ -122,6 +127,17 @@ function parseAggregate(aggregateData) {
122
127
  });
123
128
  });
124
129
 
130
+ // Build lifecycle map: { create → [event, ...], update → [...], delete → [...], softDelete → [...] }
131
+ // Used by templates to emit raise() calls at CRUD lifecycle points.
132
+ const VALID_LIFECYCLE_VALUES = ['create', 'update', 'delete', 'softDelete'];
133
+ const lifecycleEventsMap = {};
134
+ domainEvents.forEach(event => {
135
+ if (event.lifecycle && VALID_LIFECYCLE_VALUES.includes(event.lifecycle)) {
136
+ if (!lifecycleEventsMap[event.lifecycle]) lifecycleEventsMap[event.lifecycle] = [];
137
+ lifecycleEventsMap[event.lifecycle].push(event);
138
+ }
139
+ });
140
+
125
141
  return {
126
142
  name: toPascalCase(name),
127
143
  packageName: aggregateData.package || '',
@@ -132,6 +148,7 @@ function parseAggregate(aggregateData) {
132
148
  allEntities: parsedEntities,
133
149
  domainEvents,
134
150
  triggeredEventsMap,
151
+ lifecycleEventsMap,
135
152
  enums: aggregateEnums
136
153
  };
137
154
  }
@@ -1253,6 +1270,255 @@ function parsePorts(domainData, moduleName = '') {
1253
1270
  });
1254
1271
  }
1255
1272
 
1273
+ /**
1274
+ * Derive a Kafka topic name from an event class name.
1275
+ * Strips trailing 'Event' suffix, then converts to SCREAMING_SNAKE_CASE.
1276
+ * e.g. 'ProductCreatedEvent' → 'PRODUCT_CREATED'
1277
+ * 'OrderCancelled' → 'ORDER_CANCELLED'
1278
+ * @param {string} eventName - PascalCase event name
1279
+ * @returns {string} SCREAMING_SNAKE_CASE topic name
1280
+ */
1281
+ function deriveTopicFromEventName(eventName) {
1282
+ const base = eventName.endsWith('Event') ? eventName.slice(0, -5) : eventName;
1283
+ // PascalCase → SCREAMING_SNAKE_CASE
1284
+ return base
1285
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
1286
+ .replace(/([A-Z])([A-Z][a-z])/g, '$1_$2')
1287
+ .toUpperCase();
1288
+ }
1289
+
1290
+ /**
1291
+ * Parse the optional readModels section from domain.yaml.
1292
+ * Declares local read model projections maintained via domain events
1293
+ * from external bounded contexts.
1294
+ * @param {Object} domainData - Raw parsed YAML data
1295
+ * @param {string} moduleName - Current module name
1296
+ * @param {Array} ports - Parsed ports (for RM-009 warning)
1297
+ * @returns {Array} Parsed read models array (empty if not declared)
1298
+ */
1299
+ function parseReadModels(domainData, moduleName = '', ports = []) {
1300
+ if (!domainData.readModels || !Array.isArray(domainData.readModels)) return [];
1301
+
1302
+ const validActions = ['UPSERT', 'DELETE', 'SOFT_DELETE'];
1303
+
1304
+ return domainData.readModels.map(rm => {
1305
+ const name = toPascalCase(rm.name);
1306
+
1307
+ // RM-001: name must end with 'ReadModel'
1308
+ if (!name.endsWith('ReadModel')) {
1309
+ throw new Error(
1310
+ `[RM-001] Read model "${name}": name must end with "ReadModel" suffix. ` +
1311
+ `Rename to "${name}ReadModel".`
1312
+ );
1313
+ }
1314
+
1315
+ // RM-002: tableName must start with 'rm_'
1316
+ if (!rm.tableName || !rm.tableName.startsWith('rm_')) {
1317
+ throw new Error(
1318
+ `[RM-002] Read model "${name}": tableName must start with "rm_" prefix. ` +
1319
+ `Got: "${rm.tableName || '(empty)'}".`
1320
+ );
1321
+ }
1322
+
1323
+ // Source is required
1324
+ if (!rm.source || !rm.source.module || !rm.source.aggregate) {
1325
+ throw new Error(
1326
+ `[RM-010] Read model "${name}": source.module and source.aggregate are required.`
1327
+ );
1328
+ }
1329
+
1330
+ // RM-010: source.module must differ from current module
1331
+ if (rm.source.module === moduleName || toKebabCase(rm.source.module) === toKebabCase(moduleName)) {
1332
+ throw new Error(
1333
+ `[RM-010] Read model "${name}": source.module ("${rm.source.module}") is the same as the current module. ` +
1334
+ `Read models are for cross-module projections only.`
1335
+ );
1336
+ }
1337
+
1338
+ // Parse fields
1339
+ const fields = (rm.fields || []).map(f => ({
1340
+ name: toCamelCase(f.name),
1341
+ javaType: f.type
1342
+ }));
1343
+
1344
+ // RM-004: fields must include an 'id' field
1345
+ if (!fields.some(f => f.name === 'id')) {
1346
+ throw new Error(
1347
+ `[RM-004] Read model "${name}": fields must include an "id" field.`
1348
+ );
1349
+ }
1350
+
1351
+ // Parse syncedBy
1352
+ const syncedBy = rm.syncedBy || [];
1353
+
1354
+ // RM-005: syncedBy must have at least one entry
1355
+ if (syncedBy.length === 0) {
1356
+ throw new Error(
1357
+ `[RM-005] Read model "${name}": syncedBy must have at least one entry.`
1358
+ );
1359
+ }
1360
+
1361
+ // Derive the source aggregate's id key for payload extraction
1362
+ // e.g., source.aggregate = 'Product' → sourceIdKey = 'productId'
1363
+ const sourceBase = rm.source.aggregate.charAt(0).toLowerCase() + rm.source.aggregate.slice(1);
1364
+ const sourceIdKey = sourceBase + 'Id';
1365
+
1366
+ const parsedSyncedBy = syncedBy.map(sync => {
1367
+ const eventName = toPascalCase(sync.event);
1368
+ const action = (sync.action || '').toUpperCase();
1369
+
1370
+ // RM-006: action must be valid
1371
+ if (!validActions.includes(action)) {
1372
+ throw new Error(
1373
+ `[RM-006] Read model "${name}", event "${eventName}": ` +
1374
+ `syncedBy action must be one of ${validActions.join(', ')}. Got: "${action}".`
1375
+ );
1376
+ }
1377
+
1378
+ const baseName = eventName.endsWith('Event') ? eventName.slice(0, -5) : eventName;
1379
+ const topic = sync.topic || deriveTopicFromEventName(eventName);
1380
+ const topicKey = topic.toLowerCase().replace(/_/g, '-');
1381
+
1382
+ return {
1383
+ event: eventName,
1384
+ eventBaseName: baseName,
1385
+ action,
1386
+ integrationEventClassName: `${baseName}IntegrationEvent`,
1387
+ listenerClassName: `${baseName}ReadModelListener`,
1388
+ topicConstant: topic,
1389
+ topicKey,
1390
+ topicSpringProperty: `\${topics.${topicKey}}`,
1391
+ topicVariableName: toCamelCase(topicKey.replace(/-/g, '_')),
1392
+ // UPSERT needs all readModel fields; DELETE/SOFT_DELETE only need the id
1393
+ // payloadKey maps readModel field names to producer payload keys
1394
+ // e.g., readModel 'id' → payload 'productId' (convention {entityName}Id)
1395
+ fields: action === 'UPSERT'
1396
+ ? fields.map(f => ({ ...f, payloadKey: f.name === 'id' ? sourceIdKey : f.name }))
1397
+ : [{ name: 'id', javaType: 'String', payloadKey: sourceIdKey }]
1398
+ };
1399
+ });
1400
+
1401
+ const hasSoftDelete = parsedSyncedBy.some(s => s.action === 'SOFT_DELETE');
1402
+ const sourceName = toPascalCase(rm.source.aggregate);
1403
+ const sourceModule = rm.source.module;
1404
+
1405
+ // RM-009: warn if ports still has sync calls to the same source module
1406
+ const conflictingPorts = ports.filter(p =>
1407
+ p.target && (p.target === sourceModule || toKebabCase(p.target) === toKebabCase(sourceModule))
1408
+ );
1409
+ if (conflictingPorts.length > 0) {
1410
+ const serviceNames = conflictingPorts.map(p => p.serviceName).join(', ');
1411
+ console.warn(
1412
+ `⚠️ [RM-009] Read model "${name}": ports: section still contains sync calls ` +
1413
+ `to module "${sourceModule}" (services: ${serviceNames}). Consider removing them ` +
1414
+ `since the read model provides local access to that data.`
1415
+ );
1416
+ }
1417
+
1418
+ return {
1419
+ name,
1420
+ sourceName,
1421
+ sourceModule,
1422
+ tableName: rm.tableName,
1423
+ fields,
1424
+ syncedBy: parsedSyncedBy,
1425
+ hasSoftDelete,
1426
+ // Derived class names
1427
+ domainClassName: name,
1428
+ jpaEntityName: `${name}Jpa`,
1429
+ jpaRepositoryName: `${name}JpaRepository`,
1430
+ repositoryName: `${name}Repository`,
1431
+ repositoryImplName: `${name}RepositoryImpl`,
1432
+ syncHandlerName: `Sync${sourceName}ReadModelHandler`
1433
+ };
1434
+ });
1435
+ }
1436
+
1437
+ /**
1438
+ * Resolve event constructor arguments for a domain event given entity context.
1439
+ * Replicates the same resolution logic used in AggregateRoot.java.ejs for transitions.
1440
+ *
1441
+ * @param {Object} event - Parsed domain event { name, fields }
1442
+ * @param {string} entityName - PascalCase aggregate root name (e.g. 'Product')
1443
+ * @param {Array} entityFields - Parsed fields of the root entity
1444
+ * @param {Array} valueObjects - Parsed value objects of the aggregate
1445
+ * @param {string} prefix - Expression prefix for getters (e.g. 'this' or 'updated' or 'entity')
1446
+ * @returns {Object} { args: string[], needsLocalDateTime: boolean, needsUUID: boolean }
1447
+ */
1448
+ function resolveEventArgs(event, entityName, entityFields, valueObjects = [], prefix = 'this') {
1449
+ const entityBase = entityName.charAt(0).toLowerCase() + entityName.slice(1);
1450
+ const args = [`${prefix}.getId()`];
1451
+ let needsLocalDateTime = false;
1452
+ let needsUUID = false;
1453
+
1454
+ (event.fields || []).forEach(ef => {
1455
+ // Skip {entityName}Id — already provided as aggregateId in the DomainEvent constructor
1456
+ if (ef.name === entityBase + 'Id') return;
1457
+
1458
+ const matched = entityFields.find(f => f.name === ef.name);
1459
+ if (matched) {
1460
+ if (matched.javaType === ef.javaType) {
1461
+ const cap = ef.name.charAt(0).toUpperCase() + ef.name.slice(1);
1462
+ args.push(`${prefix}.get${cap}()`);
1463
+ return;
1464
+ }
1465
+ // Type mismatch: entity field may be a VO wrapping the expected primitive/type
1466
+ const vo = (valueObjects || []).find(v => v.name === matched.javaType);
1467
+ if (vo) {
1468
+ const voSub = vo.fields.find(voF => voF.name === ef.name && voF.javaType === ef.javaType)
1469
+ || vo.fields.find(voF => voF.javaType === ef.javaType);
1470
+ if (voSub) {
1471
+ const oCap = ef.name.charAt(0).toUpperCase() + ef.name.slice(1);
1472
+ const sCap = voSub.name.charAt(0).toUpperCase() + voSub.name.slice(1);
1473
+ args.push(`${prefix}.get${oCap}().get${sCap}()`);
1474
+ return;
1475
+ }
1476
+ }
1477
+ // Enum → String: convert via .name()
1478
+ if (matched.isEnum && ef.javaType === 'String') {
1479
+ const cap = ef.name.charAt(0).toUpperCase() + ef.name.slice(1);
1480
+ args.push(`${prefix}.get${cap}().name()`);
1481
+ return;
1482
+ }
1483
+ args.push(`null /* TODO: provide ${ef.name} (entity returns ${matched.javaType}, expected ${ef.javaType}) */`);
1484
+ return;
1485
+ }
1486
+ if (ef.name.endsWith('At') && ef.javaType === 'LocalDateTime') {
1487
+ args.push('LocalDateTime.now()');
1488
+ needsLocalDateTime = true;
1489
+ return;
1490
+ }
1491
+ args.push(`null /* TODO: provide ${ef.name} */`);
1492
+ });
1493
+
1494
+ return { args, needsLocalDateTime, needsUUID };
1495
+ }
1496
+
1497
+ /**
1498
+ * Pre-compute resolved arguments for all lifecycle events.
1499
+ * Returns an enriched lifecycleEventsMap where each event has a `resolvedArgs` array.
1500
+ *
1501
+ * @param {Object} lifecycleEventsMap - { create: [event, ...], update: [...], ... }
1502
+ * @param {string} entityName - PascalCase aggregate root name
1503
+ * @param {Array} entityFields - Parsed fields of the root entity
1504
+ * @param {Array} valueObjects - Parsed value objects of the aggregate
1505
+ * @returns {Object} enriched map: { create: [{ ...event, resolvedArgs, needsLocalDateTime }], ... }
1506
+ */
1507
+ function resolveLifecycleEventArgs(lifecycleEventsMap, entityName, entityFields, valueObjects = []) {
1508
+ const resolved = {};
1509
+ // Each lifecycle type uses a different variable name in the generated code
1510
+ // update uses 'this' because raise() is now inside the entity's update() method
1511
+ const prefixMap = { create: 'this', update: 'this', delete: 'entity', softDelete: 'this' };
1512
+ for (const [lifecycle, events] of Object.entries(lifecycleEventsMap)) {
1513
+ const prefix = prefixMap[lifecycle] || 'this';
1514
+ resolved[lifecycle] = events.map(event => {
1515
+ const { args, needsLocalDateTime } = resolveEventArgs(event, entityName, entityFields, valueObjects, prefix);
1516
+ return { ...event, resolvedArgs: args, needsLocalDateTime };
1517
+ });
1518
+ }
1519
+ return resolved;
1520
+ }
1521
+
1256
1522
  module.exports = {
1257
1523
  parseDomainYaml,
1258
1524
  parseAggregate,
@@ -1263,5 +1529,8 @@ module.exports = {
1263
1529
  generateValidationImports,
1264
1530
  generateAggregateMethodImports,
1265
1531
  parseListeners,
1266
- parsePorts
1532
+ parsePorts,
1533
+ parseReadModels,
1534
+ resolveEventArgs,
1535
+ resolveLifecycleEventArgs
1267
1536
  };