eventmodeler 0.2.4 → 0.2.5

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.
@@ -824,138 +824,6 @@ function applyEvent(model, event) {
824
824
  case 'ScreenDesignUpdated':
825
825
  // Screen designs are visual-only, not relevant for CLI
826
826
  break;
827
- // Compound slice placement events - place entire slices atomically
828
- case 'StateChangeSlicePlaced': {
829
- // Create slice
830
- model.slices.set(event.sliceId, {
831
- id: event.sliceId,
832
- name: event.sliceName,
833
- status: 'created',
834
- position: event.slicePosition,
835
- size: event.sliceSize,
836
- nodeIds: [event.screenId, event.commandId, event.eventId],
837
- });
838
- // Create screen
839
- model.screens.set(event.screenId, {
840
- id: event.screenId,
841
- name: event.screenName,
842
- fields: event.screenFields,
843
- position: event.screenPosition,
844
- width: event.screenSize.width,
845
- height: event.screenSize.height,
846
- });
847
- // Create command
848
- model.commands.set(event.commandId, {
849
- id: event.commandId,
850
- name: event.commandName,
851
- fields: event.commandFields,
852
- position: event.commandPosition,
853
- width: event.commandSize.width,
854
- height: event.commandSize.height,
855
- });
856
- // Create event
857
- model.events.set(event.eventId, {
858
- id: event.eventId,
859
- name: event.eventName,
860
- fields: event.eventFields,
861
- position: event.eventPosition,
862
- width: event.eventSize.width,
863
- height: event.eventSize.height,
864
- });
865
- // Create screen->command flow
866
- model.flows.set(event.screenToCommandFlowId, {
867
- id: event.screenToCommandFlowId,
868
- flowType: 'ScreenToCommand',
869
- sourceId: event.screenId,
870
- targetId: event.commandId,
871
- fieldMappings: event.screenToCommandMappings,
872
- });
873
- // Create command->event flow
874
- model.flows.set(event.commandToEventFlowId, {
875
- id: event.commandToEventFlowId,
876
- flowType: 'CommandToEvent',
877
- sourceId: event.commandId,
878
- targetId: event.eventId,
879
- fieldMappings: event.commandToEventMappings,
880
- });
881
- break;
882
- }
883
- case 'AutomationSlicePlaced': {
884
- // Create slice
885
- model.slices.set(event.sliceId, {
886
- id: event.sliceId,
887
- name: event.sliceName,
888
- status: 'created',
889
- position: event.slicePosition,
890
- size: event.sliceSize,
891
- nodeIds: [event.processorId, event.commandId, event.eventId],
892
- });
893
- // Create processor
894
- model.processors.set(event.processorId, {
895
- id: event.processorId,
896
- name: event.processorName,
897
- fields: [],
898
- position: event.processorPosition,
899
- width: event.processorSize.width,
900
- height: event.processorSize.height,
901
- });
902
- // Create command
903
- model.commands.set(event.commandId, {
904
- id: event.commandId,
905
- name: event.commandName,
906
- fields: event.commandFields,
907
- position: event.commandPosition,
908
- width: event.commandSize.width,
909
- height: event.commandSize.height,
910
- });
911
- // Create event
912
- model.events.set(event.eventId, {
913
- id: event.eventId,
914
- name: event.eventName,
915
- fields: event.eventFields,
916
- position: event.eventPosition,
917
- width: event.eventSize.width,
918
- height: event.eventSize.height,
919
- });
920
- // Create processor->command flow
921
- model.flows.set(event.processorToCommandFlowId, {
922
- id: event.processorToCommandFlowId,
923
- flowType: 'ProcessorToCommand',
924
- sourceId: event.processorId,
925
- targetId: event.commandId,
926
- fieldMappings: [],
927
- });
928
- // Create command->event flow
929
- model.flows.set(event.commandToEventFlowId, {
930
- id: event.commandToEventFlowId,
931
- flowType: 'CommandToEvent',
932
- sourceId: event.commandId,
933
- targetId: event.eventId,
934
- fieldMappings: event.commandToEventMappings,
935
- });
936
- break;
937
- }
938
- case 'StateViewSlicePlaced': {
939
- // Create slice
940
- model.slices.set(event.sliceId, {
941
- id: event.sliceId,
942
- name: event.sliceName,
943
- status: 'created',
944
- position: event.slicePosition,
945
- size: event.sliceSize,
946
- nodeIds: [event.readModelId],
947
- });
948
- // Create read model
949
- model.readModels.set(event.readModelId, {
950
- id: event.readModelId,
951
- name: event.readModelName,
952
- fields: event.readModelFields,
953
- position: event.readModelPosition,
954
- width: event.readModelSize.width,
955
- height: event.readModelSize.height,
956
- });
957
- break;
958
- }
959
827
  }
960
828
  }
961
829
  // Merge propagated fields with existing fields:
@@ -207,15 +207,20 @@ export function addScenario(model, filePath, sliceName, input) {
207
207
  };
208
208
  }
209
209
  // Calculate position below slice
210
+ // Scenarios are tall (GWT rows with 100px stickies + labels + padding = ~400px+)
211
+ // so we need adequate spacing between them
212
+ const SCENARIO_GAP = 20; // Match slice gap
213
+ const SCENARIO_ESTIMATED_HEIGHT = 450; // Approximate rendered height for positioning
210
214
  const existingScenarios = [...model.scenarios.values()].filter(s => s.sliceId === slice.id);
211
215
  const sliceBottom = slice.position.y + slice.size.height;
212
216
  let positionY;
213
217
  if (existingScenarios.length === 0) {
214
- positionY = sliceBottom + 20;
218
+ positionY = sliceBottom + SCENARIO_GAP;
215
219
  }
216
220
  else {
217
- const lowestY = Math.max(...existingScenarios.map(s => s.position.y + s.height));
218
- positionY = lowestY + 10;
221
+ // Use estimated height since stored height (80px) doesn't reflect actual rendered size
222
+ const lowestY = Math.max(...existingScenarios.map(s => s.position.y + SCENARIO_ESTIMATED_HEIGHT));
223
+ positionY = lowestY + SCENARIO_GAP;
219
224
  }
220
225
  const position = {
221
226
  x: slice.position.x + 10,
@@ -23,7 +23,7 @@ function findElementByName(model, name) {
23
23
  }
24
24
  for (const rm of model.readModels.values()) {
25
25
  if (rm.id.toLowerCase().startsWith(idSearch)) {
26
- return { element: { id: rm.id, name: rm.name, fields: rm.fields, type: 'readModel' }, ambiguous: [] };
26
+ return { element: { id: rm.id, name: rm.name, fields: rm.fields, type: 'readModel', canonicalId: rm.canonicalId }, ambiguous: [] };
27
27
  }
28
28
  }
29
29
  for (const scr of model.screens.values()) {
@@ -49,7 +49,7 @@ function findElementByName(model, name) {
49
49
  return { element: { id: evt.id, name: evt.name, fields: evt.fields, type: 'event' }, ambiguous: [] };
50
50
  const rm = model.readModels.get(name);
51
51
  if (rm)
52
- return { element: { id: rm.id, name: rm.name, fields: rm.fields, type: 'readModel' }, ambiguous: [] };
52
+ return { element: { id: rm.id, name: rm.name, fields: rm.fields, type: 'readModel', canonicalId: rm.canonicalId }, ambiguous: [] };
53
53
  const scr = model.screens.get(name);
54
54
  if (scr)
55
55
  return { element: { id: scr.id, name: scr.name, fields: scr.fields, type: 'screen' }, ambiguous: [] };
@@ -73,7 +73,7 @@ function findElementByName(model, name) {
73
73
  }
74
74
  for (const rm of model.readModels.values()) {
75
75
  if (rm.name.toLowerCase() === nameLower) {
76
- matches.push({ element: { id: rm.id, name: rm.name, fields: rm.fields, type: 'readModel' }, type: 'readModel' });
76
+ matches.push({ element: { id: rm.id, name: rm.name, fields: rm.fields, type: 'readModel', canonicalId: rm.canonicalId }, type: 'readModel' });
77
77
  }
78
78
  }
79
79
  for (const scr of model.screens.values()) {
@@ -123,6 +123,95 @@ function flattenFields(fields, prefix = '') {
123
123
  }
124
124
  return result;
125
125
  }
126
+ /**
127
+ * Calculates completeness for a read model using the union approach.
128
+ * A field is satisfied if ANY incoming event provides it.
129
+ * For linked copies, considers flows into ALL nodes in the canonical group.
130
+ */
131
+ function calculateReadModelUnionCompleteness(model, readModelId, readModelFields, canonicalId) {
132
+ // Find all node IDs in the canonical group (original + all copies)
133
+ let targetNodeIds;
134
+ if (canonicalId) {
135
+ targetNodeIds = [...model.readModels.values()]
136
+ .filter(rm => rm.canonicalId === canonicalId)
137
+ .map(rm => rm.id);
138
+ }
139
+ else {
140
+ targetNodeIds = [readModelId];
141
+ }
142
+ // Find all incoming EventToReadModel flows to ANY node in the group
143
+ const incomingFlows = [...model.flows.values()].filter(f => targetNodeIds.includes(f.targetId) && f.flowType === 'EventToReadModel');
144
+ // Collect all source fields from all incoming events (union)
145
+ const allSourceFields = [];
146
+ const allManualMappings = [];
147
+ const sourceEventNames = [];
148
+ for (const flow of incomingFlows) {
149
+ const sourceFields = getSourceFields(model, flow.sourceId);
150
+ allSourceFields.push(...flattenFields(sourceFields));
151
+ if (flow.fieldMappings) {
152
+ allManualMappings.push(...flow.fieldMappings);
153
+ }
154
+ // Get event name for reporting
155
+ const event = model.events.get(flow.sourceId);
156
+ if (event && !sourceEventNames.includes(event.name)) {
157
+ sourceEventNames.push(event.name);
158
+ }
159
+ }
160
+ // Check each target field against the union of all sources
161
+ const flatTarget = flattenFields(readModelFields);
162
+ const untraceableFields = [];
163
+ const optionalMissingFields = [];
164
+ const fieldStatuses = [];
165
+ for (const { id, path, field } of flatTarget) {
166
+ // 1. Generated fields don't need a source
167
+ if (field.isGenerated) {
168
+ fieldStatuses.push({ fieldId: id, fieldName: path, status: 'generated' });
169
+ continue;
170
+ }
171
+ // 2. Check if there's a manual mapping from any flow
172
+ const manualMapping = allManualMappings.find(m => m.targetFieldId === id);
173
+ if (manualMapping) {
174
+ const sourceField = allSourceFields.find(sf => sf.id === manualMapping.sourceFieldId);
175
+ fieldStatuses.push({
176
+ fieldId: id,
177
+ fieldName: path,
178
+ status: 'satisfied',
179
+ sourceFieldId: manualMapping.sourceFieldId,
180
+ sourceFieldName: sourceField?.path,
181
+ });
182
+ continue;
183
+ }
184
+ // 3. Check auto-mapping by path match from any source
185
+ const autoMatch = allSourceFields.find(sf => sf.path === path);
186
+ if (autoMatch) {
187
+ fieldStatuses.push({
188
+ fieldId: id,
189
+ fieldName: path,
190
+ status: 'satisfied',
191
+ sourceFieldId: autoMatch.id,
192
+ sourceFieldName: autoMatch.path,
193
+ });
194
+ continue;
195
+ }
196
+ // 4. Optional fields missing is a warning, not an error
197
+ if (field.isOptional) {
198
+ optionalMissingFields.push(path);
199
+ fieldStatuses.push({ fieldId: id, fieldName: path, status: 'optional-missing' });
200
+ continue;
201
+ }
202
+ // Field is untraceable (required but no source)
203
+ untraceableFields.push(path);
204
+ fieldStatuses.push({ fieldId: id, fieldName: path, status: 'unsatisfied' });
205
+ }
206
+ return {
207
+ isComplete: untraceableFields.length === 0,
208
+ hasWarnings: optionalMissingFields.length > 0,
209
+ untraceableFields,
210
+ optionalMissingFields,
211
+ sourceEvents: sourceEventNames,
212
+ fieldStatuses,
213
+ };
214
+ }
126
215
  function calculateFlowCompleteness(model, flow, targetFields, sourceFields) {
127
216
  const flatSource = flattenFields(sourceFields);
128
217
  const flatTarget = flattenFields(targetFields);
@@ -254,10 +343,112 @@ export function showCompleteness(model, elementName, format) {
254
343
  }
255
344
  process.exit(1);
256
345
  }
257
- // Find all incoming flows for this element type
346
+ const elementTypeLabel = element.type === 'readModel' ? 'read-model' : element.type;
347
+ // For read models, use union completeness (all incoming events combined)
348
+ if (element.type === 'readModel') {
349
+ const unionResult = calculateReadModelUnionCompleteness(model, element.id, element.fields, element.canonicalId);
350
+ // Check if there are any incoming flows at all
351
+ let targetNodeIds;
352
+ if (element.canonicalId) {
353
+ targetNodeIds = [...model.readModels.values()]
354
+ .filter(rm => rm.canonicalId === element.canonicalId)
355
+ .map(rm => rm.id);
356
+ }
357
+ else {
358
+ targetNodeIds = [element.id];
359
+ }
360
+ const hasIncomingFlows = [...model.flows.values()].some(f => targetNodeIds.includes(f.targetId) && f.flowType === 'EventToReadModel');
361
+ if (!hasIncomingFlows) {
362
+ if (format === 'json') {
363
+ outputJson({
364
+ elementType: elementTypeLabel,
365
+ name: element.name,
366
+ message: `No flows into this ${elementTypeLabel}`
367
+ });
368
+ }
369
+ else {
370
+ console.log(`<completeness ${elementTypeLabel}="${escapeXml(element.name)}">`);
371
+ console.log(` <no-flows>No flows into this ${elementTypeLabel}</no-flows>`);
372
+ console.log('</completeness>');
373
+ }
374
+ return;
375
+ }
376
+ const overallStatus = unionResult.isComplete ? 'complete' : 'incomplete';
377
+ const flatTargetFields = flattenFields(element.fields);
378
+ if (format === 'json') {
379
+ outputJson({
380
+ elementType: elementTypeLabel,
381
+ name: element.name,
382
+ status: overallStatus,
383
+ ...(unionResult.hasWarnings ? { hasWarnings: true } : {}),
384
+ sourceEvents: unionResult.sourceEvents,
385
+ ...(unionResult.untraceableFields.length > 0 ? { untraceableFields: unionResult.untraceableFields } : {}),
386
+ ...(unionResult.optionalMissingFields.length > 0 ? { optionalMissingFields: unionResult.optionalMissingFields } : {}),
387
+ fieldStatuses: unionResult.fieldStatuses.map(f => ({
388
+ name: f.fieldName,
389
+ status: f.status,
390
+ ...(f.sourceFieldName ? { source: f.sourceFieldName } : {})
391
+ })),
392
+ elementFields: flatTargetFields.map(({ id, path, field }) => ({
393
+ id,
394
+ name: path,
395
+ type: field.fieldType,
396
+ ...(field.isList ? { isList: true } : {}),
397
+ ...(field.isOptional ? { isOptional: true } : {}),
398
+ ...(field.isGenerated ? { isGenerated: true } : {})
399
+ }))
400
+ });
401
+ return;
402
+ }
403
+ // XML output for read models
404
+ console.log(`<completeness ${elementTypeLabel}="${escapeXml(element.name)}" status="${overallStatus}"${unionResult.hasWarnings ? ' hasWarnings="true"' : ''}>`);
405
+ console.log(' <source-events>');
406
+ for (const eventName of unionResult.sourceEvents) {
407
+ console.log(` <event name="${escapeXml(eventName)}"/>`);
408
+ }
409
+ console.log(' </source-events>');
410
+ if (unionResult.untraceableFields.length > 0) {
411
+ console.log(' <untraceable-fields>');
412
+ for (const fieldName of unionResult.untraceableFields) {
413
+ console.log(` <field name="${escapeXml(fieldName)}"/>`);
414
+ }
415
+ console.log(' </untraceable-fields>');
416
+ }
417
+ if (unionResult.optionalMissingFields.length > 0) {
418
+ console.log(' <optional-missing-fields>');
419
+ for (const fieldName of unionResult.optionalMissingFields) {
420
+ console.log(` <field name="${escapeXml(fieldName)}"/>`);
421
+ }
422
+ console.log(' </optional-missing-fields>');
423
+ }
424
+ console.log(' <field-statuses>');
425
+ for (const field of unionResult.fieldStatuses) {
426
+ if (field.sourceFieldName) {
427
+ console.log(` <field name="${escapeXml(field.fieldName)}" status="${field.status}" source="${escapeXml(field.sourceFieldName)}"/>`);
428
+ }
429
+ else {
430
+ console.log(` <field name="${escapeXml(field.fieldName)}" status="${field.status}"/>`);
431
+ }
432
+ }
433
+ console.log(' </field-statuses>');
434
+ console.log(` <${elementTypeLabel}-fields>`);
435
+ for (const { id, path, field } of flatTargetFields) {
436
+ const attrs = [`id="${id}"`, `name="${escapeXml(path)}"`, `type="${field.fieldType}"`];
437
+ if (field.isList)
438
+ attrs.push('isList="true"');
439
+ if (field.isOptional)
440
+ attrs.push('isOptional="true"');
441
+ if (field.isGenerated)
442
+ attrs.push('isGenerated="true"');
443
+ console.log(` <field ${attrs.join(' ')}/>`);
444
+ }
445
+ console.log(` </${elementTypeLabel}-fields>`);
446
+ console.log('</completeness>');
447
+ return;
448
+ }
449
+ // For non-read-model elements, use per-flow completeness
258
450
  const validFlowTypes = INCOMING_FLOW_TYPES[element.type];
259
451
  const incomingFlows = [...model.flows.values()].filter(f => f.targetId === element.id && validFlowTypes.includes(f.flowType));
260
- const elementTypeLabel = element.type === 'readModel' ? 'read-model' : element.type;
261
452
  if (incomingFlows.length === 0) {
262
453
  if (format === 'json') {
263
454
  outputJson({
@@ -367,14 +558,22 @@ export function showModelCompleteness(model, format) {
367
558
  const incompleteFlows = [];
368
559
  let completeCount = 0;
369
560
  let totalCount = 0;
561
+ // For read models, we use union completeness - track which canonical groups we've processed
562
+ const processedReadModelGroups = new Set();
563
+ const incompleteReadModels = [];
564
+ let readModelCompleteCount = 0;
565
+ let readModelTotalCount = 0;
370
566
  for (const flow of allFlows) {
567
+ // Skip EventToReadModel flows - we handle read models separately with union logic
568
+ if (flow.flowType === 'EventToReadModel') {
569
+ continue;
570
+ }
371
571
  // Get target element info
372
572
  let targetName = 'Unknown';
373
573
  let targetType = 'unknown';
374
574
  let targetFields = [];
375
575
  const targetCmd = model.commands.get(flow.targetId);
376
576
  const targetEvt = model.events.get(flow.targetId);
377
- const targetRm = model.readModels.get(flow.targetId);
378
577
  const targetScr = model.screens.get(flow.targetId);
379
578
  const targetProc = model.processors.get(flow.targetId);
380
579
  if (targetCmd) {
@@ -387,11 +586,6 @@ export function showModelCompleteness(model, format) {
387
586
  targetType = 'event';
388
587
  targetFields = targetEvt.fields;
389
588
  }
390
- else if (targetRm) {
391
- targetName = targetRm.name;
392
- targetType = 'read-model';
393
- targetFields = targetRm.fields;
394
- }
395
589
  else if (targetScr) {
396
590
  targetName = targetScr.name;
397
591
  targetType = 'screen';
@@ -423,13 +617,51 @@ export function showModelCompleteness(model, format) {
423
617
  });
424
618
  }
425
619
  }
620
+ // Process read models with union completeness
621
+ // Group read models by canonicalId (or use id if no canonicalId)
622
+ for (const rm of model.readModels.values()) {
623
+ const groupKey = rm.canonicalId ?? rm.id;
624
+ // Skip if we've already processed this canonical group
625
+ if (processedReadModelGroups.has(groupKey)) {
626
+ continue;
627
+ }
628
+ processedReadModelGroups.add(groupKey);
629
+ // Check if this read model has any incoming EventToReadModel flows
630
+ let targetNodeIds;
631
+ if (rm.canonicalId) {
632
+ targetNodeIds = [...model.readModels.values()]
633
+ .filter(r => r.canonicalId === rm.canonicalId)
634
+ .map(r => r.id);
635
+ }
636
+ else {
637
+ targetNodeIds = [rm.id];
638
+ }
639
+ const hasIncomingFlows = allFlows.some(f => targetNodeIds.includes(f.targetId) && f.flowType === 'EventToReadModel');
640
+ if (!hasIncomingFlows) {
641
+ // No incoming flows - skip this read model
642
+ continue;
643
+ }
644
+ readModelTotalCount++;
645
+ const unionResult = calculateReadModelUnionCompleteness(model, rm.id, rm.fields, rm.canonicalId);
646
+ if (unionResult.isComplete) {
647
+ readModelCompleteCount++;
648
+ }
649
+ else {
650
+ incompleteReadModels.push({
651
+ name: rm.name,
652
+ untraceableFields: unionResult.untraceableFields,
653
+ sourceEvents: unionResult.sourceEvents,
654
+ });
655
+ }
656
+ }
426
657
  const incompleteCount = totalCount - completeCount;
658
+ const readModelIncompleteCount = readModelTotalCount - readModelCompleteCount;
427
659
  if (format === 'json') {
428
660
  outputJson({
429
661
  summary: {
430
- complete: completeCount,
431
- incomplete: incompleteCount,
432
- total: totalCount
662
+ complete: completeCount + readModelCompleteCount,
663
+ incomplete: incompleteCount + readModelIncompleteCount,
664
+ total: totalCount + readModelTotalCount
433
665
  },
434
666
  incompleteFlows: incompleteFlows.map(item => ({
435
667
  from: item.sourceName,
@@ -437,12 +669,19 @@ export function showModelCompleteness(model, format) {
437
669
  targetType: item.targetType,
438
670
  flowType: item.flow.flowType,
439
671
  unsatisfiedFields: item.unsatisfiedFields
440
- }))
672
+ })),
673
+ ...(incompleteReadModels.length > 0 ? {
674
+ incompleteReadModels: incompleteReadModels.map(item => ({
675
+ name: item.name,
676
+ untraceableFields: item.untraceableFields,
677
+ sourceEvents: item.sourceEvents
678
+ }))
679
+ } : {})
441
680
  });
442
681
  return;
443
682
  }
444
683
  console.log('<model-completeness>');
445
- console.log(` <summary complete="${completeCount}" incomplete="${incompleteCount}" total="${totalCount}"/>`);
684
+ console.log(` <summary complete="${completeCount + readModelCompleteCount}" incomplete="${incompleteCount + readModelIncompleteCount}" total="${totalCount + readModelTotalCount}"/>`);
446
685
  if (incompleteFlows.length > 0) {
447
686
  console.log(' <incomplete-flows>');
448
687
  for (const item of incompleteFlows) {
@@ -458,5 +697,23 @@ export function showModelCompleteness(model, format) {
458
697
  }
459
698
  console.log(' </incomplete-flows>');
460
699
  }
700
+ if (incompleteReadModels.length > 0) {
701
+ console.log(' <incomplete-read-models>');
702
+ for (const item of incompleteReadModels) {
703
+ console.log(` <read-model name="${escapeXml(item.name)}">`);
704
+ console.log(' <untraceable-fields>');
705
+ for (const fieldName of item.untraceableFields) {
706
+ console.log(` <field name="${escapeXml(fieldName)}"/>`);
707
+ }
708
+ console.log(' </untraceable-fields>');
709
+ console.log(' <source-events>');
710
+ for (const eventName of item.sourceEvents) {
711
+ console.log(` <event name="${escapeXml(eventName)}"/>`);
712
+ }
713
+ console.log(' </source-events>');
714
+ console.log(' </read-model>');
715
+ }
716
+ console.log(' </incomplete-read-models>');
717
+ }
461
718
  console.log('</model-completeness>');
462
719
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eventmodeler",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "CLI tool for interacting with Event Model files - query, update, and export event models from the terminal",
5
5
  "type": "module",
6
6
  "bin": {