eventmodeler 0.3.2 → 0.3.4

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.
@@ -0,0 +1,390 @@
1
+ import { diffModels } from './model-differ.js';
2
+ import { resolvePropertyConflict, isLayoutOnlyChange, categorizeFieldChanges, } from './merge-rules.js';
3
+ function groupChangesByEntity(diff) {
4
+ const entityChanges = new Map();
5
+ const flowChanges = new Map();
6
+ for (const change of diff.entityChanges) {
7
+ entityChanges.set(change.entityId, change);
8
+ }
9
+ for (const change of diff.flowChanges) {
10
+ flowChanges.set(change.flowId, change);
11
+ }
12
+ return { entityChanges, flowChanges };
13
+ }
14
+ // Check if two entity changes are semantically identical
15
+ function changesAreIdentical(a, b) {
16
+ if (a.changeType !== b.changeType)
17
+ return false;
18
+ if (a.entityType !== b.entityType)
19
+ return false;
20
+ if (a.changeType === 'added' || a.changeType === 'removed') {
21
+ return true;
22
+ }
23
+ const aProps = JSON.stringify(a.propertyChanges ?? []);
24
+ const bProps = JSON.stringify(b.propertyChanges ?? []);
25
+ const aFields = JSON.stringify(a.fieldChanges ?? []);
26
+ const bFields = JSON.stringify(b.fieldChanges ?? []);
27
+ return aProps === bProps && aFields === bFields;
28
+ }
29
+ /**
30
+ * Analyze two modifications and determine conflicts.
31
+ */
32
+ function analyzeModificationConflicts(oursChange, theirsChange, strategy) {
33
+ const conflicts = [];
34
+ const autoResolved = [];
35
+ let hasHardConflict = false;
36
+ const entityType = oursChange.entityType;
37
+ const entityId = oursChange.entityId;
38
+ const entityName = oursChange.entityName;
39
+ // Analyze property conflicts
40
+ const oursProps = new Map((oursChange.propertyChanges ?? []).map(p => [p.property, p]));
41
+ const theirsProps = new Map((theirsChange.propertyChanges ?? []).map(p => [p.property, p]));
42
+ // Properties only in ours or theirs: auto-merge
43
+ for (const [prop, change] of oursProps) {
44
+ if (!theirsProps.has(prop)) {
45
+ autoResolved.push({
46
+ entityType,
47
+ entityId,
48
+ entityName,
49
+ action: `property-${prop}-from-ours`,
50
+ });
51
+ }
52
+ }
53
+ for (const [prop, change] of theirsProps) {
54
+ if (!oursProps.has(prop)) {
55
+ autoResolved.push({
56
+ entityType,
57
+ entityId,
58
+ entityName,
59
+ action: `property-${prop}-from-theirs`,
60
+ });
61
+ }
62
+ }
63
+ // Properties in both: check for conflicts
64
+ for (const [prop, oursProp] of oursProps) {
65
+ const theirsProp = theirsProps.get(prop);
66
+ if (!theirsProp)
67
+ continue;
68
+ const resolution = resolvePropertyConflict(prop, oursProp.newValue, theirsProp.newValue, entityType);
69
+ if (resolution.type === 'auto') {
70
+ autoResolved.push({
71
+ entityType,
72
+ entityId,
73
+ entityName,
74
+ action: resolution.action,
75
+ });
76
+ }
77
+ else if (resolution.type === 'hard') {
78
+ if (strategy) {
79
+ autoResolved.push({
80
+ entityType,
81
+ entityId,
82
+ entityName,
83
+ action: `${prop}-resolved-${strategy}`,
84
+ });
85
+ }
86
+ else {
87
+ hasHardConflict = true;
88
+ conflicts.push({
89
+ entityType,
90
+ entityId,
91
+ entityName,
92
+ reason: resolution.reason,
93
+ property: prop,
94
+ oursValue: oursProp.newValue,
95
+ theirsValue: theirsProp.newValue,
96
+ });
97
+ }
98
+ }
99
+ }
100
+ // Analyze field conflicts
101
+ const oursFields = oursChange.fieldChanges ?? [];
102
+ const theirsFields = theirsChange.fieldChanges ?? [];
103
+ if (oursFields.length > 0 || theirsFields.length > 0) {
104
+ const { oursOnly, theirsOnly, conflicts: fieldConflicts } = categorizeFieldChanges(oursFields, theirsFields);
105
+ // Fields only in one branch: auto-merge
106
+ for (const field of oursOnly) {
107
+ autoResolved.push({
108
+ entityType,
109
+ entityId,
110
+ entityName,
111
+ action: `field-${field.fieldPath}-from-ours`,
112
+ });
113
+ }
114
+ for (const field of theirsOnly) {
115
+ autoResolved.push({
116
+ entityType,
117
+ entityId,
118
+ entityName,
119
+ action: `field-${field.fieldPath}-from-theirs`,
120
+ });
121
+ }
122
+ // Field conflicts
123
+ for (const { ours, theirs, resolution } of fieldConflicts) {
124
+ if (resolution.type === 'auto') {
125
+ autoResolved.push({
126
+ entityType,
127
+ entityId,
128
+ entityName,
129
+ action: `field-${ours.fieldPath}-${resolution.action}`,
130
+ });
131
+ }
132
+ else if (resolution.type === 'hard') {
133
+ if (strategy) {
134
+ autoResolved.push({
135
+ entityType,
136
+ entityId,
137
+ entityName,
138
+ action: `field-${ours.fieldPath}-resolved-${strategy}`,
139
+ });
140
+ }
141
+ else {
142
+ hasHardConflict = true;
143
+ conflicts.push({
144
+ entityType,
145
+ entityId,
146
+ entityName,
147
+ reason: resolution.reason,
148
+ property: `field:${ours.fieldPath}`,
149
+ oursValue: ours.newValue ?? ours.oldValue,
150
+ theirsValue: theirs.newValue ?? theirs.oldValue,
151
+ });
152
+ }
153
+ }
154
+ }
155
+ }
156
+ return { conflicts, autoResolved, hasHardConflict };
157
+ }
158
+ /**
159
+ * Perform a three-way merge of event models.
160
+ */
161
+ export function threeWayMerge(baseModel, oursModel, theirsModel, baseEvents, oursEvents, theirsEvents, strategy) {
162
+ const conflicts = [];
163
+ const autoResolved = [];
164
+ // Compute diffs from base
165
+ const oursDiff = diffModels(baseModel, oursModel);
166
+ const theirsDiff = diffModels(baseModel, theirsModel);
167
+ const oursChanges = groupChangesByEntity(oursDiff);
168
+ const theirsChanges = groupChangesByEntity(theirsDiff);
169
+ const baseTimestamp = baseEvents.length > 0
170
+ ? Math.max(...baseEvents.map((e) => e.timestamp))
171
+ : 0;
172
+ const oursNewEvents = oursEvents.filter((e) => e.timestamp > baseTimestamp);
173
+ const theirsNewEvents = theirsEvents.filter((e) => e.timestamp > baseTimestamp);
174
+ const conflictingEntityIds = new Set();
175
+ const conflictingFlowIds = new Set();
176
+ // Analyze entity changes
177
+ const allEntityIds = new Set([
178
+ ...oursChanges.entityChanges.keys(),
179
+ ...theirsChanges.entityChanges.keys(),
180
+ ]);
181
+ for (const entityId of allEntityIds) {
182
+ const oursChange = oursChanges.entityChanges.get(entityId);
183
+ const theirsChange = theirsChanges.entityChanges.get(entityId);
184
+ // Only one side changed - no conflict
185
+ if (!oursChange || !theirsChange) {
186
+ continue;
187
+ }
188
+ // Both sides made the same change
189
+ if (changesAreIdentical(oursChange, theirsChange)) {
190
+ autoResolved.push({
191
+ entityType: oursChange.entityType,
192
+ entityId,
193
+ entityName: oursChange.entityName,
194
+ action: `both-${oursChange.changeType}-identically`,
195
+ });
196
+ continue;
197
+ }
198
+ // Layout-only changes: auto-resolve with theirs
199
+ if (isLayoutOnlyChange(oursChange) && isLayoutOnlyChange(theirsChange)) {
200
+ autoResolved.push({
201
+ entityType: oursChange.entityType,
202
+ entityId,
203
+ entityName: oursChange.entityName,
204
+ action: 'layout-only-use-theirs',
205
+ });
206
+ continue;
207
+ }
208
+ // One removed, other modified
209
+ if ((oursChange.changeType === 'removed' && theirsChange.changeType === 'modified') ||
210
+ (oursChange.changeType === 'modified' && theirsChange.changeType === 'removed')) {
211
+ if (strategy) {
212
+ autoResolved.push({
213
+ entityType: oursChange.entityType,
214
+ entityId,
215
+ entityName: oursChange.entityName,
216
+ action: `delete-vs-modify-resolved-${strategy}`,
217
+ });
218
+ }
219
+ else {
220
+ conflictingEntityIds.add(entityId);
221
+ conflicts.push({
222
+ entityType: oursChange.entityType,
223
+ entityId,
224
+ entityName: oursChange.entityName,
225
+ reason: oursChange.changeType === 'removed'
226
+ ? 'Ours deleted, theirs modified'
227
+ : 'Ours modified, theirs deleted',
228
+ oursValue: oursChange.changeType,
229
+ theirsValue: theirsChange.changeType,
230
+ });
231
+ }
232
+ continue;
233
+ }
234
+ // Both modified - detailed analysis
235
+ if (oursChange.changeType === 'modified' && theirsChange.changeType === 'modified') {
236
+ const analysis = analyzeModificationConflicts(oursChange, theirsChange, strategy);
237
+ autoResolved.push(...analysis.autoResolved);
238
+ conflicts.push(...analysis.conflicts);
239
+ if (analysis.hasHardConflict) {
240
+ conflictingEntityIds.add(entityId);
241
+ }
242
+ continue;
243
+ }
244
+ // Both added with same ID
245
+ if (oursChange.changeType === 'added' && theirsChange.changeType === 'added') {
246
+ if (strategy) {
247
+ autoResolved.push({
248
+ entityType: oursChange.entityType,
249
+ entityId,
250
+ entityName: oursChange.entityName,
251
+ action: `both-added-resolved-${strategy}`,
252
+ });
253
+ }
254
+ else {
255
+ conflictingEntityIds.add(entityId);
256
+ conflicts.push({
257
+ entityType: oursChange.entityType,
258
+ entityId,
259
+ entityName: oursChange.entityName,
260
+ reason: 'Both added entity with same ID',
261
+ oursValue: oursChange.entityName,
262
+ theirsValue: theirsChange.entityName,
263
+ });
264
+ }
265
+ }
266
+ }
267
+ // Analyze flow changes
268
+ const allFlowIds = new Set([
269
+ ...oursChanges.flowChanges.keys(),
270
+ ...theirsChanges.flowChanges.keys(),
271
+ ]);
272
+ for (const flowId of allFlowIds) {
273
+ const oursChange = oursChanges.flowChanges.get(flowId);
274
+ const theirsChange = theirsChanges.flowChanges.get(flowId);
275
+ if (!oursChange || !theirsChange)
276
+ continue;
277
+ // Both added same flow
278
+ if (oursChange.changeType === 'added' && theirsChange.changeType === 'added') {
279
+ autoResolved.push({
280
+ entityType: 'flow',
281
+ entityId: flowId,
282
+ entityName: `${oursChange.sourceName} → ${oursChange.targetName}`,
283
+ action: 'both-added-flow-keep-one',
284
+ });
285
+ continue;
286
+ }
287
+ // Both removed
288
+ if (oursChange.changeType === 'removed' && theirsChange.changeType === 'removed') {
289
+ autoResolved.push({
290
+ entityType: 'flow',
291
+ entityId: flowId,
292
+ entityName: `${oursChange.sourceName} → ${oursChange.targetName}`,
293
+ action: 'both-removed-identically',
294
+ });
295
+ continue;
296
+ }
297
+ // One removed, other modified
298
+ if ((oursChange.changeType === 'removed' && theirsChange.changeType === 'modified') ||
299
+ (oursChange.changeType === 'modified' && theirsChange.changeType === 'removed')) {
300
+ if (strategy) {
301
+ autoResolved.push({
302
+ entityType: 'flow',
303
+ entityId: flowId,
304
+ entityName: `${oursChange.sourceName} → ${oursChange.targetName}`,
305
+ action: `flow-delete-vs-modify-resolved-${strategy}`,
306
+ });
307
+ }
308
+ else {
309
+ conflictingFlowIds.add(flowId);
310
+ conflicts.push({
311
+ entityType: 'flow',
312
+ entityId: flowId,
313
+ entityName: `${oursChange.sourceName} → ${oursChange.targetName}`,
314
+ reason: oursChange.changeType === 'removed'
315
+ ? 'Ours removed flow, theirs modified mappings'
316
+ : 'Ours modified mappings, theirs removed flow',
317
+ oursValue: oursChange.changeType,
318
+ theirsValue: theirsChange.changeType,
319
+ });
320
+ }
321
+ continue;
322
+ }
323
+ // Both modified mappings
324
+ if (oursChange.changeType === 'modified' && theirsChange.changeType === 'modified') {
325
+ if (strategy) {
326
+ autoResolved.push({
327
+ entityType: 'flow',
328
+ entityId: flowId,
329
+ entityName: `${oursChange.sourceName} → ${oursChange.targetName}`,
330
+ action: `flow-mappings-resolved-${strategy}`,
331
+ });
332
+ }
333
+ else {
334
+ conflictingFlowIds.add(flowId);
335
+ conflicts.push({
336
+ entityType: 'flow',
337
+ entityId: flowId,
338
+ entityName: `${oursChange.sourceName} → ${oursChange.targetName}`,
339
+ reason: 'Both modified flow field mappings',
340
+ oursValue: oursChange.mappingChanges,
341
+ theirsValue: theirsChange.mappingChanges,
342
+ });
343
+ }
344
+ }
345
+ }
346
+ // Build merged events
347
+ const mergedEvents = [...baseEvents];
348
+ const affectsConflictingEntity = (event) => {
349
+ const idFields = [
350
+ 'commandStickyId', 'eventStickyId', 'readModelStickyId',
351
+ 'screenId', 'processorId', 'sliceId', 'chapterId',
352
+ 'aggregateId', 'actorId', 'scenarioId', 'flowId',
353
+ ];
354
+ for (const field of idFields) {
355
+ const id = event[field];
356
+ if (id && (conflictingEntityIds.has(id) || conflictingFlowIds.has(id))) {
357
+ return true;
358
+ }
359
+ }
360
+ return false;
361
+ };
362
+ // Add non-conflicting events from ours
363
+ for (const event of oursNewEvents) {
364
+ if (!affectsConflictingEntity(event)) {
365
+ mergedEvents.push(event);
366
+ }
367
+ else if (strategy === 'ours') {
368
+ mergedEvents.push(event);
369
+ }
370
+ }
371
+ // Add non-conflicting events from theirs
372
+ const oursEventKeys = new Set(oursNewEvents.map(e => `${e.type}:${JSON.stringify(e)}`));
373
+ for (const event of theirsNewEvents) {
374
+ const eventKey = `${event.type}:${JSON.stringify(event)}`;
375
+ if (oursEventKeys.has(eventKey))
376
+ continue;
377
+ if (!affectsConflictingEntity(event)) {
378
+ mergedEvents.push(event);
379
+ }
380
+ else if (strategy === 'theirs') {
381
+ mergedEvents.push(event);
382
+ }
383
+ }
384
+ return {
385
+ success: conflicts.length === 0,
386
+ conflicts,
387
+ autoResolved,
388
+ mergedEvents,
389
+ };
390
+ }
@@ -0,0 +1,75 @@
1
+ import type { RawEvent, Field } from '../../types.js';
2
+ export type EntityType = 'command' | 'event' | 'readModel' | 'screen' | 'processor' | 'slice' | 'chapter' | 'aggregate' | 'actor' | 'scenario' | 'flow';
3
+ export type ChangeType = 'added' | 'removed' | 'modified';
4
+ export interface FieldChange {
5
+ fieldPath: string;
6
+ changeType: ChangeType;
7
+ oldValue?: Field;
8
+ newValue?: Field;
9
+ }
10
+ export interface EntityChange {
11
+ entityType: EntityType;
12
+ entityId: string;
13
+ entityName: string;
14
+ canonicalId?: string;
15
+ changeType: ChangeType;
16
+ propertyChanges?: PropertyChange[];
17
+ fieldChanges?: FieldChange[];
18
+ }
19
+ export interface PropertyChange {
20
+ property: string;
21
+ oldValue?: unknown;
22
+ newValue?: unknown;
23
+ }
24
+ export interface FlowChange {
25
+ flowId: string;
26
+ flowType: string;
27
+ changeType: ChangeType;
28
+ sourceName?: string;
29
+ targetName?: string;
30
+ mappingChanges?: FieldMappingChange[];
31
+ }
32
+ export interface FieldMappingChange {
33
+ changeType: ChangeType;
34
+ sourceFieldName?: string;
35
+ targetFieldName?: string;
36
+ }
37
+ export interface DiffResult {
38
+ summary: {
39
+ added: number;
40
+ removed: number;
41
+ modified: number;
42
+ };
43
+ entityChanges: EntityChange[];
44
+ flowChanges: FlowChange[];
45
+ }
46
+ export interface MergeConflict {
47
+ entityType: EntityType;
48
+ entityId: string;
49
+ entityName: string;
50
+ reason: string;
51
+ property?: string;
52
+ baseValue?: unknown;
53
+ oursValue?: unknown;
54
+ theirsValue?: unknown;
55
+ }
56
+ export interface MergeResult {
57
+ success: boolean;
58
+ conflicts: MergeConflict[];
59
+ autoResolved: AutoResolution[];
60
+ mergedEvents: RawEvent[];
61
+ }
62
+ export interface AutoResolution {
63
+ entityType: EntityType;
64
+ entityId: string;
65
+ entityName: string;
66
+ action: string;
67
+ }
68
+ export interface MergeOptions {
69
+ basePath: string;
70
+ oursPath: string;
71
+ theirsPath: string;
72
+ outputPath: string;
73
+ strategy?: 'ours' | 'theirs';
74
+ dryRun?: boolean;
75
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -10,6 +10,15 @@ export type LookupResult<T> = {
10
10
  error: 'not_found' | 'ambiguous';
11
11
  matches: T[];
12
12
  };
13
+ /**
14
+ * Filter out linked copies from a collection of elements.
15
+ * Linked copies have `originalNodeId` set and should not be considered
16
+ * as separate elements for lookup - they're UI conveniences only.
17
+ */
18
+ export declare function excludeLinkedCopies<T extends {
19
+ id: string;
20
+ originalNodeId?: string;
21
+ }>(elements: Map<string, T> | T[]): T[];
13
22
  /**
14
23
  * Find an element by name (fuzzy) or UUID.
15
24
  * - If search starts with "id:", treats rest as UUID/UUID prefix
@@ -2,6 +2,15 @@
2
2
  * Element lookup utilities for CLI commands.
3
3
  * Provides fuzzy lookup with UUID disambiguation for ambiguous matches.
4
4
  */
5
+ /**
6
+ * Filter out linked copies from a collection of elements.
7
+ * Linked copies have `originalNodeId` set and should not be considered
8
+ * as separate elements for lookup - they're UI conveniences only.
9
+ */
10
+ export function excludeLinkedCopies(elements) {
11
+ const elementArray = Array.isArray(elements) ? elements : [...elements.values()];
12
+ return elementArray.filter(e => !e.originalNodeId);
13
+ }
5
14
  /**
6
15
  * Normalize a string for fuzzy matching:
7
16
  * - lowercase
@@ -3,3 +3,6 @@ export declare function promptForFile(files: string[]): Promise<string>;
3
3
  export declare function findEventModelFile(): Promise<string | null>;
4
4
  export declare function loadModel(filePath: string): EventModel;
5
5
  export declare function appendEvent(filePath: string, event: RawEvent): void;
6
+ export declare function loadRawEvents(filePath: string): RawEvent[];
7
+ export declare function writeEvents(filePath: string, events: RawEvent[]): void;
8
+ export declare function loadModelFromContent(content: string): EventModel;
@@ -51,3 +51,27 @@ export function loadModel(filePath) {
51
51
  export function appendEvent(filePath, event) {
52
52
  fs.appendFileSync(filePath, JSON.stringify(event) + '\n');
53
53
  }
54
+ export function loadRawEvents(filePath) {
55
+ const content = fs.readFileSync(filePath, 'utf-8');
56
+ if (!content.trim()) {
57
+ return [];
58
+ }
59
+ return content
60
+ .trim()
61
+ .split('\n')
62
+ .map(line => JSON.parse(line));
63
+ }
64
+ export function writeEvents(filePath, events) {
65
+ const content = events.map(e => JSON.stringify(e)).join('\n') + '\n';
66
+ fs.writeFileSync(filePath, content);
67
+ }
68
+ export function loadModelFromContent(content) {
69
+ if (!content.trim()) {
70
+ return projectEvents([]);
71
+ }
72
+ const events = content
73
+ .trim()
74
+ .split('\n')
75
+ .map(line => JSON.parse(line));
76
+ return projectEvents(events);
77
+ }
@@ -26,6 +26,7 @@ export interface SliceFlowInfo extends FlowInfo {
26
26
  name: string;
27
27
  };
28
28
  }
29
+ export declare function resolveToCanonical(model: EventModel, id: string): string;
29
30
  export declare function getElementInfo(model: EventModel, id: string): ElementInfo | null;
30
31
  export declare function getSliceComponentIds(model: EventModel, slice: Slice): Set<string>;
31
32
  export declare function findSliceForComponent(model: EventModel, componentId: string): Slice | null;