eventmodeler 0.1.0 → 0.2.1

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,339 @@
1
+ import * as crypto from 'node:crypto';
2
+ import { appendEvent } from '../../lib/file-loader.js';
3
+ function parseJsonInput(input) {
4
+ return JSON.parse(input);
5
+ }
6
+ function parseXmlInput(input) {
7
+ // Simple XML parser for scenario format
8
+ const getAttr = (tag, attr) => {
9
+ const match = tag.match(new RegExp(`${attr}="([^"]*)"`));
10
+ return match ? match[1] : undefined;
11
+ };
12
+ const getContent = (xml, tagName) => {
13
+ const match = xml.match(new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)</${tagName}>`));
14
+ return match ? match[1].trim() : undefined;
15
+ };
16
+ const scenarioMatch = input.match(/<scenario([^>]*)>/);
17
+ if (!scenarioMatch) {
18
+ throw new Error('Invalid XML: missing <scenario> tag');
19
+ }
20
+ const name = getAttr(scenarioMatch[1], 'name');
21
+ if (!name) {
22
+ throw new Error('Invalid XML: scenario must have a name attribute');
23
+ }
24
+ const description = getAttr(scenarioMatch[1], 'description');
25
+ // Parse given events
26
+ const givenContent = getContent(input, 'given');
27
+ const given = [];
28
+ if (givenContent) {
29
+ const eventMatches = givenContent.matchAll(/<event([^>]*?)(?:\/>|>([\s\S]*?)<\/event>)/g);
30
+ for (const match of eventMatches) {
31
+ const eventName = getAttr(match[1], 'name');
32
+ if (eventName) {
33
+ const fieldValues = {};
34
+ if (match[2]) {
35
+ const fieldMatches = match[2].matchAll(/<field name="([^"]*)"[^>]*>([^<]*)<\/field>/g);
36
+ for (const fieldMatch of fieldMatches) {
37
+ fieldValues[fieldMatch[1]] = parseFieldValue(fieldMatch[2]);
38
+ }
39
+ }
40
+ given.push({ event: eventName, fieldValues: Object.keys(fieldValues).length > 0 ? fieldValues : undefined });
41
+ }
42
+ }
43
+ }
44
+ // Parse when command
45
+ const whenContent = getContent(input, 'when');
46
+ let when;
47
+ if (whenContent) {
48
+ const commandMatch = whenContent.match(/<command([^>]*?)(?:\/>|>([\s\S]*?)<\/command>)/);
49
+ if (commandMatch) {
50
+ const commandName = getAttr(commandMatch[1], 'name');
51
+ if (commandName) {
52
+ const fieldValues = {};
53
+ if (commandMatch[2]) {
54
+ const fieldMatches = commandMatch[2].matchAll(/<field name="([^"]*)"[^>]*>([^<]*)<\/field>/g);
55
+ for (const fieldMatch of fieldMatches) {
56
+ fieldValues[fieldMatch[1]] = parseFieldValue(fieldMatch[2]);
57
+ }
58
+ }
59
+ when = { command: commandName, fieldValues: Object.keys(fieldValues).length > 0 ? fieldValues : undefined };
60
+ }
61
+ }
62
+ }
63
+ // Parse then
64
+ const thenMatch = input.match(/<then([^>]*)(?:\/>|>([\s\S]*?)<\/then>)/);
65
+ if (!thenMatch) {
66
+ throw new Error('Invalid XML: missing <then> tag');
67
+ }
68
+ const thenType = getAttr(thenMatch[1], 'type');
69
+ if (!thenType) {
70
+ throw new Error('Invalid XML: <then> must have a type attribute');
71
+ }
72
+ const then = { type: thenType };
73
+ if (thenType === 'error') {
74
+ then.errorType = getAttr(thenMatch[1], 'errorType');
75
+ then.errorMessage = thenMatch[2]?.trim();
76
+ }
77
+ else if (thenType === 'events' && thenMatch[2]) {
78
+ then.events = [];
79
+ const eventMatches = thenMatch[2].matchAll(/<event([^>]*?)(?:\/>|>([\s\S]*?)<\/event>)/g);
80
+ for (const match of eventMatches) {
81
+ const eventName = getAttr(match[1], 'name');
82
+ if (eventName) {
83
+ const fieldValues = {};
84
+ if (match[2]) {
85
+ const fieldMatches = match[2].matchAll(/<field name="([^"]*)"[^>]*>([^<]*)<\/field>/g);
86
+ for (const fieldMatch of fieldMatches) {
87
+ fieldValues[fieldMatch[1]] = parseFieldValue(fieldMatch[2]);
88
+ }
89
+ }
90
+ then.events.push({ event: eventName, fieldValues: Object.keys(fieldValues).length > 0 ? fieldValues : undefined });
91
+ }
92
+ }
93
+ }
94
+ else if (thenType === 'readModelAssertion' && thenMatch[2]) {
95
+ const rmMatch = thenMatch[2].match(/<read-model([^>]*?)(?:\/>|>([\s\S]*?)<\/read-model>)/);
96
+ if (rmMatch) {
97
+ then.readModel = getAttr(rmMatch[1], 'name');
98
+ then.expected = {};
99
+ if (rmMatch[2]) {
100
+ const fieldMatches = rmMatch[2].matchAll(/<field name="([^"]*)"[^>]*>([^<]*)<\/field>/g);
101
+ for (const fieldMatch of fieldMatches) {
102
+ then.expected[fieldMatch[1]] = parseFieldValue(fieldMatch[2]);
103
+ }
104
+ }
105
+ }
106
+ }
107
+ return { name, description, given: given.length > 0 ? given : undefined, when, then };
108
+ }
109
+ function parseFieldValue(value) {
110
+ // Try to parse as JSON for complex values, otherwise return as string
111
+ const trimmed = value.trim();
112
+ if (trimmed === 'true')
113
+ return true;
114
+ if (trimmed === 'false')
115
+ return false;
116
+ if (trimmed === 'null')
117
+ return null;
118
+ if (/^-?\d+$/.test(trimmed))
119
+ return parseInt(trimmed, 10);
120
+ if (/^-?\d+\.\d+$/.test(trimmed))
121
+ return parseFloat(trimmed);
122
+ try {
123
+ return JSON.parse(trimmed);
124
+ }
125
+ catch {
126
+ return trimmed;
127
+ }
128
+ }
129
+ function parseInput(input) {
130
+ const trimmed = input.trim();
131
+ if (trimmed.startsWith('<')) {
132
+ return parseXmlInput(trimmed);
133
+ }
134
+ return parseJsonInput(trimmed);
135
+ }
136
+ export function addScenario(model, filePath, sliceName, input) {
137
+ // Parse input
138
+ let scenarioInput;
139
+ try {
140
+ scenarioInput = parseInput(input);
141
+ }
142
+ catch (err) {
143
+ console.error(`Error: Invalid input format: ${err.message}`);
144
+ process.exit(1);
145
+ }
146
+ // Find slice by name
147
+ const slices = [...model.slices.values()];
148
+ const sliceNameLower = sliceName.toLowerCase();
149
+ const slice = slices.find(s => s.name.toLowerCase() === sliceNameLower || s.name.toLowerCase().includes(sliceNameLower));
150
+ if (!slice) {
151
+ console.error(`Error: Slice not found: ${sliceName}`);
152
+ console.error('Available slices:');
153
+ for (const s of slices) {
154
+ console.error(` - ${s.name}`);
155
+ }
156
+ process.exit(1);
157
+ }
158
+ // Resolve event references in given
159
+ const givenEvents = [];
160
+ if (scenarioInput.given) {
161
+ for (const g of scenarioInput.given) {
162
+ const event = findEventByName(model, g.event);
163
+ if (!event) {
164
+ console.error(`Error: Event not found in "given": ${g.event}`);
165
+ listAvailableEvents(model);
166
+ process.exit(1);
167
+ }
168
+ givenEvents.push({
169
+ eventStickyId: event.id,
170
+ fieldValues: g.fieldValues,
171
+ });
172
+ }
173
+ }
174
+ // Resolve command reference in when
175
+ let whenCommand = null;
176
+ if (scenarioInput.when) {
177
+ const command = findCommandByName(model, scenarioInput.when.command);
178
+ if (!command) {
179
+ console.error(`Error: Command not found in "when": ${scenarioInput.when.command}`);
180
+ listAvailableCommands(model);
181
+ process.exit(1);
182
+ }
183
+ whenCommand = {
184
+ commandStickyId: command.id,
185
+ fieldValues: scenarioInput.when.fieldValues,
186
+ };
187
+ }
188
+ // Resolve then clause
189
+ const then = { type: scenarioInput.then.type };
190
+ if (scenarioInput.then.type === 'error') {
191
+ then.errorMessage = scenarioInput.then.errorMessage;
192
+ then.errorType = scenarioInput.then.errorType;
193
+ }
194
+ else if (scenarioInput.then.type === 'events') {
195
+ then.expectedEvents = [];
196
+ if (scenarioInput.then.events) {
197
+ for (const e of scenarioInput.then.events) {
198
+ const event = findEventByName(model, e.event);
199
+ if (!event) {
200
+ console.error(`Error: Event not found in "then.events": ${e.event}`);
201
+ listAvailableEvents(model);
202
+ process.exit(1);
203
+ }
204
+ then.expectedEvents.push({
205
+ eventStickyId: event.id,
206
+ fieldValues: e.fieldValues,
207
+ });
208
+ }
209
+ }
210
+ }
211
+ else if (scenarioInput.then.type === 'readModelAssertion') {
212
+ if (!scenarioInput.then.readModel) {
213
+ console.error('Error: readModelAssertion requires a readModel name');
214
+ process.exit(1);
215
+ }
216
+ const readModel = findReadModelByName(model, scenarioInput.then.readModel);
217
+ if (!readModel) {
218
+ console.error(`Error: Read model not found: ${scenarioInput.then.readModel}`);
219
+ listAvailableReadModels(model);
220
+ process.exit(1);
221
+ }
222
+ const assertionGivenEvents = [];
223
+ if (scenarioInput.then.givenEvents) {
224
+ for (const g of scenarioInput.then.givenEvents) {
225
+ const event = findEventByName(model, g.event);
226
+ if (!event) {
227
+ console.error(`Error: Event not found in "then.givenEvents": ${g.event}`);
228
+ listAvailableEvents(model);
229
+ process.exit(1);
230
+ }
231
+ assertionGivenEvents.push({
232
+ eventStickyId: event.id,
233
+ fieldValues: g.fieldValues,
234
+ });
235
+ }
236
+ }
237
+ then.readModelAssertion = {
238
+ readModelStickyId: readModel.id,
239
+ givenEvents: assertionGivenEvents,
240
+ expectedFieldValues: scenarioInput.then.expected ?? {},
241
+ };
242
+ }
243
+ // Calculate position below slice
244
+ const existingScenarios = [...model.scenarios.values()].filter(s => s.sliceId === slice.id);
245
+ const sliceBottom = slice.position.y + slice.size.height;
246
+ let positionY;
247
+ if (existingScenarios.length === 0) {
248
+ positionY = sliceBottom + 20;
249
+ }
250
+ else {
251
+ const lowestY = Math.max(...existingScenarios.map(s => s.position.y + s.height));
252
+ positionY = lowestY + 10;
253
+ }
254
+ const position = {
255
+ x: slice.position.x + 10,
256
+ y: positionY,
257
+ };
258
+ // Generate scenario ID
259
+ const scenarioId = crypto.randomUUID();
260
+ // Append ScenarioCreated event
261
+ appendEvent(filePath, {
262
+ type: 'ScenarioCreated',
263
+ scenarioId,
264
+ sliceId: slice.id,
265
+ name: scenarioInput.name,
266
+ position,
267
+ width: 200,
268
+ height: 80,
269
+ timestamp: Date.now(),
270
+ });
271
+ // Append description update if provided
272
+ if (scenarioInput.description) {
273
+ appendEvent(filePath, {
274
+ type: 'ScenarioDescriptionUpdated',
275
+ scenarioId,
276
+ description: scenarioInput.description,
277
+ timestamp: Date.now(),
278
+ });
279
+ }
280
+ // Append given events update if provided
281
+ if (givenEvents.length > 0) {
282
+ appendEvent(filePath, {
283
+ type: 'ScenarioGivenEventsUpdated',
284
+ scenarioId,
285
+ givenEvents,
286
+ timestamp: Date.now(),
287
+ });
288
+ }
289
+ // Append when command update if provided
290
+ if (whenCommand) {
291
+ appendEvent(filePath, {
292
+ type: 'ScenarioWhenCommandUpdated',
293
+ scenarioId,
294
+ whenCommand,
295
+ timestamp: Date.now(),
296
+ });
297
+ }
298
+ // Append then update
299
+ appendEvent(filePath, {
300
+ type: 'ScenarioThenUpdated',
301
+ scenarioId,
302
+ then,
303
+ timestamp: Date.now(),
304
+ });
305
+ console.log(`Added scenario "${scenarioInput.name}" to slice "${slice.name}"`);
306
+ }
307
+ function findEventByName(model, name) {
308
+ const nameLower = name.toLowerCase();
309
+ const events = [...model.events.values()];
310
+ return events.find(e => e.name.toLowerCase() === nameLower || e.name.toLowerCase().includes(nameLower));
311
+ }
312
+ function findCommandByName(model, name) {
313
+ const nameLower = name.toLowerCase();
314
+ const commands = [...model.commands.values()];
315
+ return commands.find(c => c.name.toLowerCase() === nameLower || c.name.toLowerCase().includes(nameLower));
316
+ }
317
+ function findReadModelByName(model, name) {
318
+ const nameLower = name.toLowerCase();
319
+ const readModels = [...model.readModels.values()];
320
+ return readModels.find(rm => rm.name.toLowerCase() === nameLower || rm.name.toLowerCase().includes(nameLower));
321
+ }
322
+ function listAvailableEvents(model) {
323
+ console.error('Available events:');
324
+ for (const e of model.events.values()) {
325
+ console.error(` - ${e.name}`);
326
+ }
327
+ }
328
+ function listAvailableCommands(model) {
329
+ console.error('Available commands:');
330
+ for (const c of model.commands.values()) {
331
+ console.error(` - ${c.name}`);
332
+ }
333
+ }
334
+ function listAvailableReadModels(model) {
335
+ console.error('Available read models:');
336
+ for (const rm of model.readModels.values()) {
337
+ console.error(` - ${rm.name}`);
338
+ }
339
+ }
@@ -141,6 +141,60 @@ function determineSliceType(commands, events, processors) {
141
141
  // Otherwise it's a state view (read-only)
142
142
  return 'STATE_VIEW';
143
143
  }
144
+ // Check if two rectangles overlap
145
+ function rectanglesOverlap(r1, r2) {
146
+ return (r1.x < r2.x + r2.width &&
147
+ r1.x + r1.width > r2.x &&
148
+ r1.y < r2.y + r2.height &&
149
+ r1.y + r1.height > r2.y);
150
+ }
151
+ // Find aggregates that overlap with the slice
152
+ function getAggregatesForSlice(model, slice) {
153
+ const sliceRect = {
154
+ x: slice.position.x,
155
+ y: slice.position.y,
156
+ width: slice.size.width,
157
+ height: slice.size.height,
158
+ };
159
+ const aggregateNames = [];
160
+ for (const aggregate of model.aggregates.values()) {
161
+ const aggRect = {
162
+ x: aggregate.position.x,
163
+ y: aggregate.position.y,
164
+ width: aggregate.size.width,
165
+ height: aggregate.size.height,
166
+ };
167
+ if (rectanglesOverlap(sliceRect, aggRect)) {
168
+ aggregateNames.push(aggregate.name);
169
+ }
170
+ }
171
+ return aggregateNames;
172
+ }
173
+ // Find actors that overlap with the slice
174
+ function getActorsForSlice(model, slice) {
175
+ const sliceRect = {
176
+ x: slice.position.x,
177
+ y: slice.position.y,
178
+ width: slice.size.width,
179
+ height: slice.size.height,
180
+ };
181
+ const actors = [];
182
+ for (const actor of model.actors.values()) {
183
+ const actorRect = {
184
+ x: actor.position.x,
185
+ y: actor.position.y,
186
+ width: actor.size.width,
187
+ height: actor.size.height,
188
+ };
189
+ if (rectanglesOverlap(sliceRect, actorRect)) {
190
+ actors.push({
191
+ name: actor.name,
192
+ authRequired: false, // Default - we don't track this property yet
193
+ });
194
+ }
195
+ }
196
+ return actors;
197
+ }
144
198
  export function exportEventmodelToJson(model) {
145
199
  const specSlices = [];
146
200
  // Sort slices by position
@@ -157,18 +211,21 @@ export function exportEventmodelToJson(model) {
157
211
  commands.push(cmd);
158
212
  }
159
213
  }
214
+ // Filter out linked copies - only export originals
160
215
  for (const evt of model.events.values()) {
161
- if (isInSlice(slice, evt.position, evt.width, evt.height)) {
216
+ if (!evt.originalNodeId && isInSlice(slice, evt.position, evt.width, evt.height)) {
162
217
  events.push(evt);
163
218
  }
164
219
  }
220
+ // Filter out linked copies - only export originals
165
221
  for (const rm of model.readModels.values()) {
166
- if (isInSlice(slice, rm.position, rm.width, rm.height)) {
222
+ if (!rm.originalNodeId && isInSlice(slice, rm.position, rm.width, rm.height)) {
167
223
  readModels.push(rm);
168
224
  }
169
225
  }
226
+ // Filter out linked copies - only export originals
170
227
  for (const scr of model.screens.values()) {
171
- if (isInSlice(slice, scr.position, scr.width, scr.height)) {
228
+ if (!scr.originalNodeId && isInSlice(slice, scr.position, scr.width, scr.height)) {
172
229
  screens.push(scr);
173
230
  }
174
231
  }
@@ -236,6 +293,8 @@ export function exportEventmodelToJson(model) {
236
293
  ],
237
294
  })),
238
295
  tables: [],
296
+ actors: getActorsForSlice(model, slice),
297
+ aggregates: getAggregatesForSlice(model, slice),
239
298
  specifications: scenarios.map(scenario => {
240
299
  const givenSteps = scenario.givenEvents.map(given => {
241
300
  const evt = model.events.get(given.eventStickyId);
@@ -6,16 +6,72 @@ function escapeXml(str) {
6
6
  .replace(/"/g, '&quot;')
7
7
  .replace(/'/g, '&apos;');
8
8
  }
9
+ // Find which aggregate an event belongs to (center point inside aggregate bounds)
10
+ function findAggregateForEvent(model, event) {
11
+ const centerX = event.position.x + event.width / 2;
12
+ const centerY = event.position.y + event.height / 2;
13
+ for (const aggregate of model.aggregates.values()) {
14
+ const bounds = {
15
+ left: aggregate.position.x,
16
+ right: aggregate.position.x + aggregate.size.width,
17
+ top: aggregate.position.y,
18
+ bottom: aggregate.position.y + aggregate.size.height,
19
+ };
20
+ if (centerX >= bounds.left && centerX <= bounds.right && centerY >= bounds.top && centerY <= bounds.bottom) {
21
+ return aggregate;
22
+ }
23
+ }
24
+ return null;
25
+ }
26
+ // Find which slice contains an event
27
+ function findSliceForEvent(model, event) {
28
+ for (const slice of model.slices.values()) {
29
+ const centerX = event.position.x + event.width / 2;
30
+ const centerY = event.position.y + event.height / 2;
31
+ if (centerX >= slice.position.x &&
32
+ centerX <= slice.position.x + slice.size.width &&
33
+ centerY >= slice.position.y &&
34
+ centerY <= slice.position.y + slice.size.height) {
35
+ return slice.name;
36
+ }
37
+ }
38
+ return null;
39
+ }
9
40
  export function listEvents(model) {
10
41
  const events = [...model.events.values()];
11
42
  if (events.length === 0) {
12
43
  console.log('<events/>');
13
44
  return;
14
45
  }
15
- const sorted = [...events].sort((a, b) => a.name.localeCompare(b.name));
46
+ // Sort: originals first, then copies; alphabetically within each group
47
+ const sorted = [...events].sort((a, b) => {
48
+ // Originals before copies
49
+ if (!a.originalNodeId && b.originalNodeId)
50
+ return -1;
51
+ if (a.originalNodeId && !b.originalNodeId)
52
+ return 1;
53
+ // Alphabetically by name
54
+ return a.name.localeCompare(b.name);
55
+ });
16
56
  console.log('<events>');
17
57
  for (const evt of sorted) {
18
- console.log(` <event name="${escapeXml(evt.name)}" fields="${evt.fields.length}"/>`);
58
+ const aggregate = findAggregateForEvent(model, evt);
59
+ const aggregateAttr = aggregate ? ` aggregate="${escapeXml(aggregate.name)}"` : '';
60
+ if (evt.originalNodeId) {
61
+ // This is a linked copy - show origin info
62
+ const original = model.events.get(evt.originalNodeId);
63
+ const originSlice = original ? findSliceForEvent(model, original) : null;
64
+ const originAttr = originSlice ? ` origin-slice="${escapeXml(originSlice)}"` : '';
65
+ console.log(` <event name="${escapeXml(evt.name)}" fields="${evt.fields.length}"${aggregateAttr} linked-copy="true"${originAttr}/>`);
66
+ }
67
+ else {
68
+ // This is an original - show copy count
69
+ const copyCount = evt.canonicalId
70
+ ? [...model.events.values()].filter(e => e.canonicalId === evt.canonicalId && e.originalNodeId).length
71
+ : 0;
72
+ const copiesAttr = copyCount > 0 ? ` copies="${copyCount}"` : '';
73
+ console.log(` <event name="${escapeXml(evt.name)}" fields="${evt.fields.length}"${aggregateAttr}${copiesAttr}/>`);
74
+ }
19
75
  }
20
76
  console.log('</events>');
21
77
  }
@@ -0,0 +1,2 @@
1
+ import type { EventModel } from '../../types.js';
2
+ export declare function mapFields(model: EventModel, filePath: string, flowIdentifier: string, input: string): void;