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.
- package/AGENTS.md +218 -5
- package/DOMAIN_YAML_GUIDE.md +185 -2
- package/FUTURE_FEATURES.md +33 -52
- package/docs/commands/EVALUATE_SYSTEM.md +18 -2
- package/examples/domain-events.yaml +26 -0
- package/examples/domain-read-models.yaml +113 -0
- package/package.json +1 -1
- package/read-model-spec.md +664 -0
- package/src/agents/design-reviewer.agent.md +3 -0
- package/src/commands/generate-entities.js +254 -10
- package/src/commands/generate-http-exchange.js +3 -0
- package/src/commands/generate-kafka-event.js +3 -0
- package/src/commands/generate-kafka-listener.js +3 -0
- package/src/commands/generate-record.js +2 -2
- package/src/commands/generate-resource.js +4 -1
- package/src/commands/generate-temporal-activity.js +4 -1
- package/src/commands/generate-temporal-flow.js +4 -1
- package/src/commands/generate-usecase.js +4 -1
- package/src/skills/build-system-yaml/SKILL.md +122 -1
- package/src/skills/build-system-yaml/references/domain-yaml-spec.md +205 -24
- package/src/skills/build-system-yaml/references/module-spec.md +33 -1
- package/src/skills/build-system-yaml/references/system-yaml-spec.md +36 -0
- package/src/utils/config-manager.js +4 -2
- package/src/utils/domain-validator.js +230 -14
- package/src/utils/naming.js +10 -0
- package/src/utils/validator.js +3 -1
- package/src/utils/yaml-to-entity.js +272 -3
- package/templates/aggregate/AggregateRepository.java.ejs +4 -0
- package/templates/aggregate/AggregateRepositoryImpl.java.ejs +8 -0
- package/templates/aggregate/AggregateRoot.java.ejs +38 -4
- package/templates/aggregate/JpaAggregateRoot.java.ejs +2 -2
- package/templates/crud/DeleteCommandHandler.java.ejs +19 -1
- package/templates/crud/UpdateCommandHandler.java.ejs +53 -2
- package/templates/read-model/ReadModelDomain.java.ejs +46 -0
- package/templates/read-model/ReadModelJpa.java.ejs +58 -0
- package/templates/read-model/ReadModelJpaRepository.java.ejs +11 -0
- package/templates/read-model/ReadModelKafkaListener.java.ejs +64 -0
- package/templates/read-model/ReadModelRepository.java.ejs +42 -0
- package/templates/read-model/ReadModelRepositoryImpl.java.ejs +81 -0
- package/templates/read-model/ReadModelSyncHandler.java.ejs +52 -0
- package/test-c2010.js +49 -0
- package/test-update-compat.js +109 -0
- 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) =>
|
|
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
|
|
283
|
-
const consumerConfig = domainConfigs[
|
|
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
|
-
|
|
286
|
-
if (
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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;
|
package/src/utils/naming.js
CHANGED
|
@@ -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,
|
package/src/utils/validator.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
};
|