@kapeta/local-cluster-service 0.46.0 → 0.47.0

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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.47.0](https://github.com/kapetacom/local-cluster-service/compare/v0.46.0...v0.47.0) (2024-05-29)
2
+
3
+
4
+ ### Features
5
+
6
+ * Added tests for event parsing and layouting of nodes ([#149](https://github.com/kapetacom/local-cluster-service/issues/149)) ([d36afa9](https://github.com/kapetacom/local-cluster-service/commit/d36afa905d0c66c4562b4eccf07c067efe6b3c9c))
7
+
1
8
  # [0.46.0](https://github.com/kapetacom/local-cluster-service/compare/v0.45.0...v0.46.0) (2024-05-28)
2
9
 
3
10
 
@@ -10,7 +10,7 @@ export interface BlockDefinitionInfo {
10
10
  content: BlockDefinition;
11
11
  aiName: string;
12
12
  }
13
- export interface ParsedResult {
13
+ export interface StormDefinitions {
14
14
  plan: Plan;
15
15
  blocks: BlockDefinitionInfo[];
16
16
  }
@@ -51,12 +51,12 @@ export declare class StormEventParser {
51
51
  private options;
52
52
  constructor(options: StormOptions);
53
53
  private reset;
54
- addEvent(evt: StormEvent): void;
54
+ addEvent(handle: string, evt: StormEvent): StormDefinitions;
55
55
  getEvents(): StormEvent[];
56
56
  isValid(): boolean;
57
57
  getError(): string;
58
58
  private applyLayoutToBlocks;
59
- toResult(handle: string): ParsedResult;
59
+ toResult(handle: string): StormDefinitions;
60
60
  private toSafeName;
61
61
  private toRef;
62
62
  toBlockDefinitions(handle: string): {
@@ -12,6 +12,8 @@ const nodejs_utils_1 = require("@kapeta/nodejs-utils");
12
12
  const kaplang_core_1 = require("@kapeta/kaplang-core");
13
13
  const node_uuid_1 = __importDefault(require("node-uuid"));
14
14
  const definitionsManager_1 = require("../definitionsManager");
15
+ const ngraph_graph_1 = __importDefault(require("ngraph.graph"));
16
+ const ngraph_forcelayout_1 = __importDefault(require("ngraph.forcelayout"));
15
17
  async function resolveOptions() {
16
18
  // Predefined types for now - TODO: Allow user to select / change
17
19
  const blockTypeService = await definitionsManager_1.definitionsManager.getLatestDefinition('kapeta/block-type-service');
@@ -87,6 +89,7 @@ async function resolveOptions() {
87
89
  };
88
90
  }
89
91
  exports.resolveOptions = resolveOptions;
92
+ const LAYOUT_MARGIN = 50;
90
93
  class StormEventParser {
91
94
  events = [];
92
95
  planName = '';
@@ -105,7 +108,7 @@ class StormEventParser {
105
108
  this.blocks = {};
106
109
  this.connections = [];
107
110
  }
108
- addEvent(evt) {
111
+ addEvent(handle, evt) {
109
112
  this.events.push(evt);
110
113
  switch (evt.type) {
111
114
  case 'CREATE_PLAN_PROPERTIES':
@@ -144,6 +147,7 @@ class StormEventParser {
144
147
  case 'FILE':
145
148
  break;
146
149
  }
150
+ return this.toResult(handle);
147
151
  }
148
152
  getEvents() {
149
153
  return this.events;
@@ -155,13 +159,47 @@ class StormEventParser {
155
159
  return this.error;
156
160
  }
157
161
  applyLayoutToBlocks(result) {
162
+ const graph = (0, ngraph_graph_1.default)();
163
+ const blockInstances = {};
164
+ result.plan.spec.blocks.forEach((block, index) => {
165
+ graph.addNode(block.id, block);
166
+ blockInstances[block.id] = block;
167
+ });
168
+ result.plan.spec.connections.forEach((connection) => {
169
+ graph.addLink(connection.provider.blockId, connection.consumer.blockId);
170
+ });
171
+ const layout = (0, ngraph_forcelayout_1.default)(graph, {
172
+ springLength: 150,
173
+ debug: true,
174
+ dimensions: 2,
175
+ gravity: 2,
176
+ springCoefficient: 0.0008,
177
+ });
178
+ for (let i = 0; i < 100; ++i) {
179
+ layout.step();
180
+ }
181
+ // Layout might place things in negative space. We move everything to positive space
182
+ const graphBox = layout.getGraphRect();
183
+ let yAdjust = 0;
184
+ let xAdjust = 0;
185
+ if (graphBox.y1 < 0) {
186
+ yAdjust = -graphBox.y1;
187
+ }
188
+ if (graphBox.x1 < 0) {
189
+ xAdjust = -graphBox.x1;
190
+ }
191
+ graph.forEachNode((node) => {
192
+ const position = layout.getNodePosition(node.id);
193
+ blockInstances[node.id].dimensions.left = LAYOUT_MARGIN + Math.round(position.x + xAdjust);
194
+ blockInstances[node.id].dimensions.top = LAYOUT_MARGIN + Math.round(position.y + yAdjust);
195
+ });
196
+ layout.dispose();
158
197
  return result;
159
198
  }
160
199
  toResult(handle) {
161
200
  const planRef = this.toRef(handle, this.planName);
162
201
  const blockDefinitions = this.toBlockDefinitions(handle);
163
202
  const refIdMap = {};
164
- const screens = {};
165
203
  const blocks = Object.entries(blockDefinitions).map(([ref, block]) => {
166
204
  const id = node_uuid_1.default.v4();
167
205
  refIdMap[ref] = id;
@@ -174,7 +212,7 @@ class StormEventParser {
174
212
  dimensions: {
175
213
  left: 0,
176
214
  top: 0,
177
- width: 200,
215
+ width: 150,
178
216
  height: 200,
179
217
  },
180
218
  };
@@ -377,7 +415,7 @@ class StormEventParser {
377
415
  },
378
416
  },
379
417
  };
380
- blockSpec.providers.push(dbResource);
418
+ blockSpec.consumers.push(dbResource);
381
419
  break;
382
420
  case 'JWTCONSUMER':
383
421
  case 'WEBFRAGMENT':
@@ -2,6 +2,7 @@
2
2
  * Copyright 2023 Kapeta Inc.
3
3
  * SPDX-License-Identifier: BUSL-1.1
4
4
  */
5
+ import { StormDefinitions } from "./event-parser";
5
6
  export type StormResourceType = 'API' | 'DATABASE' | 'CLIENT' | 'JWTPROVIDER' | 'JWTCONSUMER' | 'WEBFRAGMENT' | 'WEBPAGE' | 'SMTPCLIENT' | 'EXTERNAL_API' | 'SUBSCRIBER' | 'PUBLISHER' | 'QUEUE' | 'EXCHANGE';
6
7
  export type StormBlockType = 'BACKEND' | 'FRONTEND' | 'GATEWAY' | 'MQ' | 'CLI' | 'DESKTOP';
7
8
  export interface StormBlockInfo {
@@ -123,4 +124,10 @@ export interface StormEventDone {
123
124
  type: 'DONE';
124
125
  created: number;
125
126
  }
126
- export type StormEvent = StormEventCreateBlock | StormEventCreateConnection | StormEventCreatePlanProperties | StormEventInvalidResponse | StormEventPlanRetry | StormEventCreateDSL | StormEventError | StormEventScreen | StormEventScreenCandidate | StormEventFile | StormEventDone;
127
+ export interface StormEventDefinitionChange {
128
+ type: 'DEFINITION_CHANGE';
129
+ reason: string;
130
+ created: number;
131
+ payload: StormDefinitions;
132
+ }
133
+ export type StormEvent = StormEventCreateBlock | StormEventCreateConnection | StormEventCreatePlanProperties | StormEventInvalidResponse | StormEventPlanRetry | StormEventCreateDSL | StormEventError | StormEventScreen | StormEventScreenCandidate | StormEventFile | StormEventDone | StormEventDefinitionChange;
@@ -1,6 +1,2 @@
1
1
  "use strict";
2
- /**
3
- * Copyright 2023 Kapeta Inc.
4
- * SPDX-License-Identifier: BUSL-1.1
5
- */
6
2
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -25,12 +25,13 @@ router.post('/:handle/all', async (req, res) => {
25
25
  const metaStream = await stormClient_1.stormClient.createMetadata(aiRequest.prompt, aiRequest.history);
26
26
  res.set('Content-Type', 'application/x-ndjson');
27
27
  metaStream.on('data', (data) => {
28
- eventParser.addEvent(data);
28
+ const result = eventParser.addEvent(req.params.handle, data);
29
+ sendDefinitions(res, result);
29
30
  });
30
31
  await streamStormPartialResponse(metaStream, res);
31
32
  if (!eventParser.isValid()) {
32
33
  // We can't continue if the meta stream is invalid
33
- res.write({
34
+ sendEvent(res, {
34
35
  type: 'ERROR_INTERNAL',
35
36
  payload: { error: eventParser.getError() },
36
37
  reason: 'Failed to generate system',
@@ -40,6 +41,7 @@ router.post('/:handle/all', async (req, res) => {
40
41
  return;
41
42
  }
42
43
  const result = eventParser.toResult(handle);
44
+ sendDefinitions(res, result);
43
45
  const stormCodegen = new codegen_1.StormCodegen(aiRequest.prompt, result.blocks, eventParser.getEvents());
44
46
  const codegenStream = streamStormPartialResponse(stormCodegen.getStream(), res);
45
47
  await stormCodegen.process();
@@ -50,22 +52,30 @@ router.post('/:handle/all', async (req, res) => {
50
52
  sendError(err, res);
51
53
  }
52
54
  });
55
+ function sendDefinitions(res, result) {
56
+ sendEvent(res, {
57
+ type: 'DEFINITION_CHANGE',
58
+ payload: result,
59
+ reason: 'Updates to definition',
60
+ created: Date.now(),
61
+ });
62
+ }
53
63
  function sendDone(res) {
54
- res.write(JSON.stringify({
64
+ sendEvent(res, {
55
65
  type: 'DONE',
56
66
  created: Date.now(),
57
- }) + '\n');
67
+ });
58
68
  res.end();
59
69
  }
60
70
  function sendError(err, res) {
61
71
  console.error('Failed to send prompt', err);
62
72
  if (res.headersSent) {
63
- res.write(JSON.stringify({
73
+ sendEvent(res, {
64
74
  type: 'ERROR_INTERNAL',
65
75
  created: Date.now(),
66
76
  payload: { error: err.message },
67
77
  reason: 'Failed while sending prompt',
68
- }) + '\n');
78
+ });
69
79
  }
70
80
  else {
71
81
  res.status(400).send({ error: err.message });
@@ -74,7 +84,7 @@ function sendError(err, res) {
74
84
  function streamStormPartialResponse(result, res) {
75
85
  return new Promise((resolve, reject) => {
76
86
  result.on('data', (data) => {
77
- res.write(JSON.stringify(data) + '\n');
87
+ sendEvent(res, data);
78
88
  });
79
89
  result.on('error', (err) => {
80
90
  reject(err);
@@ -84,4 +94,7 @@ function streamStormPartialResponse(result, res) {
84
94
  });
85
95
  });
86
96
  }
97
+ function sendEvent(res, evt) {
98
+ res.write(JSON.stringify(evt) + '\n');
99
+ }
87
100
  exports.default = router;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Copyright 2023 Kapeta Inc.
3
+ * SPDX-License-Identifier: BUSL-1.1
4
+ */
5
+ export {};
@@ -0,0 +1,169 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright 2023 Kapeta Inc.
4
+ * SPDX-License-Identifier: BUSL-1.1
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const event_parser_1 = require("../../src/storm/event-parser");
8
+ const parserOptions = {
9
+ serviceKind: 'kapeta/block-service:local',
10
+ serviceLanguage: 'kapeta/language-target-nodejs-ts:local',
11
+ frontendKind: 'kapeta/block-type-frontend:local',
12
+ frontendLanguage: 'kapeta/language-target-react-ts:local',
13
+ cliKind: 'kapeta/block-type-cli:local',
14
+ cliLanguage: 'kapeta/language-target-nodejs-ts:local',
15
+ desktopKind: 'kapeta/block-type-desktop:local',
16
+ desktopLanguage: 'kapeta/language-target-electron-ts:local',
17
+ gatewayKind: 'kapeta/block-type-gateway:local',
18
+ mqKind: 'kapeta/block-type-mq:local',
19
+ exchangeKind: 'kapeta/resource-type-exchange:local',
20
+ queueKind: 'kapeta/resource-type-queue:local',
21
+ publisherKind: 'kapeta/resource-type-publisher:local',
22
+ subscriberKind: 'kapeta/resource-type-subscriber:local',
23
+ databaseKind: 'kapeta/block-type-database:local',
24
+ apiKind: 'kapeta/block-type-api:local',
25
+ clientKind: 'kapeta/block-type-client:local',
26
+ webPageKind: 'kapeta/block-type-web-page:local',
27
+ webFragmentKind: 'kapeta/block-type-web-fragment:local',
28
+ jwtProviderKind: 'kapeta/resource-type-jwt-provider:local',
29
+ jwtConsumerKind: 'kapeta/resource-type-jwt-consumer:local',
30
+ smtpKind: 'kapeta/resource-type-smtp:local',
31
+ externalApiKind: 'kapeta/resource-type-external-api:local',
32
+ };
33
+ const events = [
34
+ {
35
+ type: 'CREATE_PLAN_PROPERTIES',
36
+ created: Date.now(),
37
+ reason: 'create plan properties',
38
+ payload: {
39
+ name: 'my-plan',
40
+ description: 'my plan description',
41
+ },
42
+ },
43
+ {
44
+ type: 'CREATE_BLOCK',
45
+ reason: 'create backend',
46
+ created: Date.now(),
47
+ payload: {
48
+ name: 'service',
49
+ description: 'A service block',
50
+ type: 'BACKEND',
51
+ resources: [
52
+ {
53
+ name: 'entities',
54
+ type: 'DATABASE',
55
+ description: 'A database resource',
56
+ },
57
+ {
58
+ type: 'API',
59
+ name: 'entities',
60
+ description: 'An API resource',
61
+ },
62
+ ],
63
+ },
64
+ },
65
+ {
66
+ type: 'CREATE_BLOCK',
67
+ reason: 'create frontend',
68
+ created: Date.now(),
69
+ payload: {
70
+ name: 'ui',
71
+ description: 'A frontend block',
72
+ type: 'FRONTEND',
73
+ resources: [
74
+ {
75
+ name: 'web',
76
+ type: 'WEBPAGE',
77
+ description: 'A web page',
78
+ },
79
+ {
80
+ type: 'CLIENT',
81
+ name: 'entities',
82
+ description: 'Client for backend',
83
+ },
84
+ ],
85
+ },
86
+ },
87
+ {
88
+ type: 'CREATE_CONNECTION',
89
+ created: Date.now(),
90
+ reason: 'connect service to ui',
91
+ payload: {
92
+ fromComponent: 'service',
93
+ fromResource: 'entities',
94
+ fromResourceType: 'API',
95
+ toComponent: 'ui',
96
+ toResource: 'entities',
97
+ toResourceType: 'CLIENT',
98
+ },
99
+ },
100
+ {
101
+ type: 'CREATE_API',
102
+ reason: 'create api',
103
+ created: Date.now(),
104
+ payload: {
105
+ blockName: 'service',
106
+ content: `controller Entities('/entities') {
107
+ @GET('/')
108
+ list(): string[]
109
+ }`,
110
+ },
111
+ },
112
+ {
113
+ type: 'CREATE_MODEL',
114
+ created: Date.now(),
115
+ reason: 'create model',
116
+ payload: {
117
+ blockName: 'service',
118
+ content: `type Entity {
119
+ @Id
120
+ id: string
121
+
122
+ name: string
123
+ }`,
124
+ },
125
+ },
126
+ ];
127
+ describe('event-parser', () => {
128
+ it('it can parse events into a plan and blocks with proper layout', () => {
129
+ const parser = new event_parser_1.StormEventParser(parserOptions);
130
+ events.forEach((event) => parser.addEvent('kapeta', event));
131
+ const result = parser.toResult('kapeta');
132
+ expect(result.plan.metadata.name).toBe('kapeta/my-plan');
133
+ expect(result.plan.metadata.description).toBe('my plan description');
134
+ expect(result.blocks.length).toBe(2);
135
+ expect(result.blocks[0].content.metadata.name).toBe('kapeta/service');
136
+ expect(result.blocks[1].content.metadata.name).toBe('kapeta/ui');
137
+ const dbResource = result.blocks[0].content.spec.consumers?.[0];
138
+ const apiResource = result.blocks[0].content.spec.providers?.[0];
139
+ const clientResource = result.blocks[1].content.spec.consumers?.[0];
140
+ const pageResource = result.blocks[1].content.spec.providers?.[0];
141
+ expect(apiResource).toBeDefined();
142
+ expect(clientResource).toBeDefined();
143
+ expect(dbResource).toBeDefined();
144
+ expect(pageResource).toBeDefined();
145
+ expect(apiResource?.kind).toBe(parserOptions.apiKind);
146
+ expect(clientResource?.kind).toBe(parserOptions.clientKind);
147
+ expect(dbResource?.kind).toBe(parserOptions.databaseKind);
148
+ expect(pageResource?.kind).toBe(parserOptions.webPageKind);
149
+ expect(apiResource?.spec).toEqual(clientResource?.spec);
150
+ expect(dbResource?.spec.source.value).toContain('type Entity');
151
+ const serviceBlockInstance = result.plan.spec.blocks[0];
152
+ expect(serviceBlockInstance.name).toBe('service');
153
+ expect(serviceBlockInstance.dimensions.width).toBe(150);
154
+ expect(serviceBlockInstance.dimensions.height).toBe(200);
155
+ expect(serviceBlockInstance.dimensions.top).toBe(3);
156
+ expect(serviceBlockInstance.dimensions.left).toBe(6);
157
+ const uiBlockInstance = result.plan.spec.blocks[1];
158
+ expect(uiBlockInstance.name).toBe('ui');
159
+ expect(uiBlockInstance.dimensions.width).toBe(150);
160
+ expect(uiBlockInstance.dimensions.height).toBe(200);
161
+ expect(uiBlockInstance.dimensions.top).toBe(107);
162
+ expect(uiBlockInstance.dimensions.left).toBe(112);
163
+ expect(result.plan.spec.connections.length).toBe(1);
164
+ expect(result.plan.spec.connections[0].consumer.blockId).toBe(uiBlockInstance.id);
165
+ expect(result.plan.spec.connections[0].consumer.resourceName).toBe(clientResource?.metadata.name);
166
+ expect(result.plan.spec.connections[0].provider.blockId).toBe(serviceBlockInstance.id);
167
+ expect(result.plan.spec.connections[0].provider.resourceName).toBe(apiResource?.metadata.name);
168
+ });
169
+ });
@@ -10,7 +10,7 @@ export interface BlockDefinitionInfo {
10
10
  content: BlockDefinition;
11
11
  aiName: string;
12
12
  }
13
- export interface ParsedResult {
13
+ export interface StormDefinitions {
14
14
  plan: Plan;
15
15
  blocks: BlockDefinitionInfo[];
16
16
  }
@@ -51,12 +51,12 @@ export declare class StormEventParser {
51
51
  private options;
52
52
  constructor(options: StormOptions);
53
53
  private reset;
54
- addEvent(evt: StormEvent): void;
54
+ addEvent(handle: string, evt: StormEvent): StormDefinitions;
55
55
  getEvents(): StormEvent[];
56
56
  isValid(): boolean;
57
57
  getError(): string;
58
58
  private applyLayoutToBlocks;
59
- toResult(handle: string): ParsedResult;
59
+ toResult(handle: string): StormDefinitions;
60
60
  private toSafeName;
61
61
  private toRef;
62
62
  toBlockDefinitions(handle: string): {
@@ -12,6 +12,8 @@ const nodejs_utils_1 = require("@kapeta/nodejs-utils");
12
12
  const kaplang_core_1 = require("@kapeta/kaplang-core");
13
13
  const node_uuid_1 = __importDefault(require("node-uuid"));
14
14
  const definitionsManager_1 = require("../definitionsManager");
15
+ const ngraph_graph_1 = __importDefault(require("ngraph.graph"));
16
+ const ngraph_forcelayout_1 = __importDefault(require("ngraph.forcelayout"));
15
17
  async function resolveOptions() {
16
18
  // Predefined types for now - TODO: Allow user to select / change
17
19
  const blockTypeService = await definitionsManager_1.definitionsManager.getLatestDefinition('kapeta/block-type-service');
@@ -87,6 +89,7 @@ async function resolveOptions() {
87
89
  };
88
90
  }
89
91
  exports.resolveOptions = resolveOptions;
92
+ const LAYOUT_MARGIN = 50;
90
93
  class StormEventParser {
91
94
  events = [];
92
95
  planName = '';
@@ -105,7 +108,7 @@ class StormEventParser {
105
108
  this.blocks = {};
106
109
  this.connections = [];
107
110
  }
108
- addEvent(evt) {
111
+ addEvent(handle, evt) {
109
112
  this.events.push(evt);
110
113
  switch (evt.type) {
111
114
  case 'CREATE_PLAN_PROPERTIES':
@@ -144,6 +147,7 @@ class StormEventParser {
144
147
  case 'FILE':
145
148
  break;
146
149
  }
150
+ return this.toResult(handle);
147
151
  }
148
152
  getEvents() {
149
153
  return this.events;
@@ -155,13 +159,47 @@ class StormEventParser {
155
159
  return this.error;
156
160
  }
157
161
  applyLayoutToBlocks(result) {
162
+ const graph = (0, ngraph_graph_1.default)();
163
+ const blockInstances = {};
164
+ result.plan.spec.blocks.forEach((block, index) => {
165
+ graph.addNode(block.id, block);
166
+ blockInstances[block.id] = block;
167
+ });
168
+ result.plan.spec.connections.forEach((connection) => {
169
+ graph.addLink(connection.provider.blockId, connection.consumer.blockId);
170
+ });
171
+ const layout = (0, ngraph_forcelayout_1.default)(graph, {
172
+ springLength: 150,
173
+ debug: true,
174
+ dimensions: 2,
175
+ gravity: 2,
176
+ springCoefficient: 0.0008,
177
+ });
178
+ for (let i = 0; i < 100; ++i) {
179
+ layout.step();
180
+ }
181
+ // Layout might place things in negative space. We move everything to positive space
182
+ const graphBox = layout.getGraphRect();
183
+ let yAdjust = 0;
184
+ let xAdjust = 0;
185
+ if (graphBox.y1 < 0) {
186
+ yAdjust = -graphBox.y1;
187
+ }
188
+ if (graphBox.x1 < 0) {
189
+ xAdjust = -graphBox.x1;
190
+ }
191
+ graph.forEachNode((node) => {
192
+ const position = layout.getNodePosition(node.id);
193
+ blockInstances[node.id].dimensions.left = LAYOUT_MARGIN + Math.round(position.x + xAdjust);
194
+ blockInstances[node.id].dimensions.top = LAYOUT_MARGIN + Math.round(position.y + yAdjust);
195
+ });
196
+ layout.dispose();
158
197
  return result;
159
198
  }
160
199
  toResult(handle) {
161
200
  const planRef = this.toRef(handle, this.planName);
162
201
  const blockDefinitions = this.toBlockDefinitions(handle);
163
202
  const refIdMap = {};
164
- const screens = {};
165
203
  const blocks = Object.entries(blockDefinitions).map(([ref, block]) => {
166
204
  const id = node_uuid_1.default.v4();
167
205
  refIdMap[ref] = id;
@@ -174,7 +212,7 @@ class StormEventParser {
174
212
  dimensions: {
175
213
  left: 0,
176
214
  top: 0,
177
- width: 200,
215
+ width: 150,
178
216
  height: 200,
179
217
  },
180
218
  };
@@ -377,7 +415,7 @@ class StormEventParser {
377
415
  },
378
416
  },
379
417
  };
380
- blockSpec.providers.push(dbResource);
418
+ blockSpec.consumers.push(dbResource);
381
419
  break;
382
420
  case 'JWTCONSUMER':
383
421
  case 'WEBFRAGMENT':
@@ -2,6 +2,7 @@
2
2
  * Copyright 2023 Kapeta Inc.
3
3
  * SPDX-License-Identifier: BUSL-1.1
4
4
  */
5
+ import { StormDefinitions } from "./event-parser";
5
6
  export type StormResourceType = 'API' | 'DATABASE' | 'CLIENT' | 'JWTPROVIDER' | 'JWTCONSUMER' | 'WEBFRAGMENT' | 'WEBPAGE' | 'SMTPCLIENT' | 'EXTERNAL_API' | 'SUBSCRIBER' | 'PUBLISHER' | 'QUEUE' | 'EXCHANGE';
6
7
  export type StormBlockType = 'BACKEND' | 'FRONTEND' | 'GATEWAY' | 'MQ' | 'CLI' | 'DESKTOP';
7
8
  export interface StormBlockInfo {
@@ -123,4 +124,10 @@ export interface StormEventDone {
123
124
  type: 'DONE';
124
125
  created: number;
125
126
  }
126
- export type StormEvent = StormEventCreateBlock | StormEventCreateConnection | StormEventCreatePlanProperties | StormEventInvalidResponse | StormEventPlanRetry | StormEventCreateDSL | StormEventError | StormEventScreen | StormEventScreenCandidate | StormEventFile | StormEventDone;
127
+ export interface StormEventDefinitionChange {
128
+ type: 'DEFINITION_CHANGE';
129
+ reason: string;
130
+ created: number;
131
+ payload: StormDefinitions;
132
+ }
133
+ export type StormEvent = StormEventCreateBlock | StormEventCreateConnection | StormEventCreatePlanProperties | StormEventInvalidResponse | StormEventPlanRetry | StormEventCreateDSL | StormEventError | StormEventScreen | StormEventScreenCandidate | StormEventFile | StormEventDone | StormEventDefinitionChange;
@@ -1,6 +1,2 @@
1
1
  "use strict";
2
- /**
3
- * Copyright 2023 Kapeta Inc.
4
- * SPDX-License-Identifier: BUSL-1.1
5
- */
6
2
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -25,12 +25,13 @@ router.post('/:handle/all', async (req, res) => {
25
25
  const metaStream = await stormClient_1.stormClient.createMetadata(aiRequest.prompt, aiRequest.history);
26
26
  res.set('Content-Type', 'application/x-ndjson');
27
27
  metaStream.on('data', (data) => {
28
- eventParser.addEvent(data);
28
+ const result = eventParser.addEvent(req.params.handle, data);
29
+ sendDefinitions(res, result);
29
30
  });
30
31
  await streamStormPartialResponse(metaStream, res);
31
32
  if (!eventParser.isValid()) {
32
33
  // We can't continue if the meta stream is invalid
33
- res.write({
34
+ sendEvent(res, {
34
35
  type: 'ERROR_INTERNAL',
35
36
  payload: { error: eventParser.getError() },
36
37
  reason: 'Failed to generate system',
@@ -40,6 +41,7 @@ router.post('/:handle/all', async (req, res) => {
40
41
  return;
41
42
  }
42
43
  const result = eventParser.toResult(handle);
44
+ sendDefinitions(res, result);
43
45
  const stormCodegen = new codegen_1.StormCodegen(aiRequest.prompt, result.blocks, eventParser.getEvents());
44
46
  const codegenStream = streamStormPartialResponse(stormCodegen.getStream(), res);
45
47
  await stormCodegen.process();
@@ -50,22 +52,30 @@ router.post('/:handle/all', async (req, res) => {
50
52
  sendError(err, res);
51
53
  }
52
54
  });
55
+ function sendDefinitions(res, result) {
56
+ sendEvent(res, {
57
+ type: 'DEFINITION_CHANGE',
58
+ payload: result,
59
+ reason: 'Updates to definition',
60
+ created: Date.now(),
61
+ });
62
+ }
53
63
  function sendDone(res) {
54
- res.write(JSON.stringify({
64
+ sendEvent(res, {
55
65
  type: 'DONE',
56
66
  created: Date.now(),
57
- }) + '\n');
67
+ });
58
68
  res.end();
59
69
  }
60
70
  function sendError(err, res) {
61
71
  console.error('Failed to send prompt', err);
62
72
  if (res.headersSent) {
63
- res.write(JSON.stringify({
73
+ sendEvent(res, {
64
74
  type: 'ERROR_INTERNAL',
65
75
  created: Date.now(),
66
76
  payload: { error: err.message },
67
77
  reason: 'Failed while sending prompt',
68
- }) + '\n');
78
+ });
69
79
  }
70
80
  else {
71
81
  res.status(400).send({ error: err.message });
@@ -74,7 +84,7 @@ function sendError(err, res) {
74
84
  function streamStormPartialResponse(result, res) {
75
85
  return new Promise((resolve, reject) => {
76
86
  result.on('data', (data) => {
77
- res.write(JSON.stringify(data) + '\n');
87
+ sendEvent(res, data);
78
88
  });
79
89
  result.on('error', (err) => {
80
90
  reject(err);
@@ -84,4 +94,7 @@ function streamStormPartialResponse(result, res) {
84
94
  });
85
95
  });
86
96
  }
97
+ function sendEvent(res, evt) {
98
+ res.write(JSON.stringify(evt) + '\n');
99
+ }
87
100
  exports.default = router;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Copyright 2023 Kapeta Inc.
3
+ * SPDX-License-Identifier: BUSL-1.1
4
+ */
5
+ export {};
@@ -0,0 +1,169 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright 2023 Kapeta Inc.
4
+ * SPDX-License-Identifier: BUSL-1.1
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const event_parser_1 = require("../../src/storm/event-parser");
8
+ const parserOptions = {
9
+ serviceKind: 'kapeta/block-service:local',
10
+ serviceLanguage: 'kapeta/language-target-nodejs-ts:local',
11
+ frontendKind: 'kapeta/block-type-frontend:local',
12
+ frontendLanguage: 'kapeta/language-target-react-ts:local',
13
+ cliKind: 'kapeta/block-type-cli:local',
14
+ cliLanguage: 'kapeta/language-target-nodejs-ts:local',
15
+ desktopKind: 'kapeta/block-type-desktop:local',
16
+ desktopLanguage: 'kapeta/language-target-electron-ts:local',
17
+ gatewayKind: 'kapeta/block-type-gateway:local',
18
+ mqKind: 'kapeta/block-type-mq:local',
19
+ exchangeKind: 'kapeta/resource-type-exchange:local',
20
+ queueKind: 'kapeta/resource-type-queue:local',
21
+ publisherKind: 'kapeta/resource-type-publisher:local',
22
+ subscriberKind: 'kapeta/resource-type-subscriber:local',
23
+ databaseKind: 'kapeta/block-type-database:local',
24
+ apiKind: 'kapeta/block-type-api:local',
25
+ clientKind: 'kapeta/block-type-client:local',
26
+ webPageKind: 'kapeta/block-type-web-page:local',
27
+ webFragmentKind: 'kapeta/block-type-web-fragment:local',
28
+ jwtProviderKind: 'kapeta/resource-type-jwt-provider:local',
29
+ jwtConsumerKind: 'kapeta/resource-type-jwt-consumer:local',
30
+ smtpKind: 'kapeta/resource-type-smtp:local',
31
+ externalApiKind: 'kapeta/resource-type-external-api:local',
32
+ };
33
+ const events = [
34
+ {
35
+ type: 'CREATE_PLAN_PROPERTIES',
36
+ created: Date.now(),
37
+ reason: 'create plan properties',
38
+ payload: {
39
+ name: 'my-plan',
40
+ description: 'my plan description',
41
+ },
42
+ },
43
+ {
44
+ type: 'CREATE_BLOCK',
45
+ reason: 'create backend',
46
+ created: Date.now(),
47
+ payload: {
48
+ name: 'service',
49
+ description: 'A service block',
50
+ type: 'BACKEND',
51
+ resources: [
52
+ {
53
+ name: 'entities',
54
+ type: 'DATABASE',
55
+ description: 'A database resource',
56
+ },
57
+ {
58
+ type: 'API',
59
+ name: 'entities',
60
+ description: 'An API resource',
61
+ },
62
+ ],
63
+ },
64
+ },
65
+ {
66
+ type: 'CREATE_BLOCK',
67
+ reason: 'create frontend',
68
+ created: Date.now(),
69
+ payload: {
70
+ name: 'ui',
71
+ description: 'A frontend block',
72
+ type: 'FRONTEND',
73
+ resources: [
74
+ {
75
+ name: 'web',
76
+ type: 'WEBPAGE',
77
+ description: 'A web page',
78
+ },
79
+ {
80
+ type: 'CLIENT',
81
+ name: 'entities',
82
+ description: 'Client for backend',
83
+ },
84
+ ],
85
+ },
86
+ },
87
+ {
88
+ type: 'CREATE_CONNECTION',
89
+ created: Date.now(),
90
+ reason: 'connect service to ui',
91
+ payload: {
92
+ fromComponent: 'service',
93
+ fromResource: 'entities',
94
+ fromResourceType: 'API',
95
+ toComponent: 'ui',
96
+ toResource: 'entities',
97
+ toResourceType: 'CLIENT',
98
+ },
99
+ },
100
+ {
101
+ type: 'CREATE_API',
102
+ reason: 'create api',
103
+ created: Date.now(),
104
+ payload: {
105
+ blockName: 'service',
106
+ content: `controller Entities('/entities') {
107
+ @GET('/')
108
+ list(): string[]
109
+ }`,
110
+ },
111
+ },
112
+ {
113
+ type: 'CREATE_MODEL',
114
+ created: Date.now(),
115
+ reason: 'create model',
116
+ payload: {
117
+ blockName: 'service',
118
+ content: `type Entity {
119
+ @Id
120
+ id: string
121
+
122
+ name: string
123
+ }`,
124
+ },
125
+ },
126
+ ];
127
+ describe('event-parser', () => {
128
+ it('it can parse events into a plan and blocks with proper layout', () => {
129
+ const parser = new event_parser_1.StormEventParser(parserOptions);
130
+ events.forEach((event) => parser.addEvent('kapeta', event));
131
+ const result = parser.toResult('kapeta');
132
+ expect(result.plan.metadata.name).toBe('kapeta/my-plan');
133
+ expect(result.plan.metadata.description).toBe('my plan description');
134
+ expect(result.blocks.length).toBe(2);
135
+ expect(result.blocks[0].content.metadata.name).toBe('kapeta/service');
136
+ expect(result.blocks[1].content.metadata.name).toBe('kapeta/ui');
137
+ const dbResource = result.blocks[0].content.spec.consumers?.[0];
138
+ const apiResource = result.blocks[0].content.spec.providers?.[0];
139
+ const clientResource = result.blocks[1].content.spec.consumers?.[0];
140
+ const pageResource = result.blocks[1].content.spec.providers?.[0];
141
+ expect(apiResource).toBeDefined();
142
+ expect(clientResource).toBeDefined();
143
+ expect(dbResource).toBeDefined();
144
+ expect(pageResource).toBeDefined();
145
+ expect(apiResource?.kind).toBe(parserOptions.apiKind);
146
+ expect(clientResource?.kind).toBe(parserOptions.clientKind);
147
+ expect(dbResource?.kind).toBe(parserOptions.databaseKind);
148
+ expect(pageResource?.kind).toBe(parserOptions.webPageKind);
149
+ expect(apiResource?.spec).toEqual(clientResource?.spec);
150
+ expect(dbResource?.spec.source.value).toContain('type Entity');
151
+ const serviceBlockInstance = result.plan.spec.blocks[0];
152
+ expect(serviceBlockInstance.name).toBe('service');
153
+ expect(serviceBlockInstance.dimensions.width).toBe(150);
154
+ expect(serviceBlockInstance.dimensions.height).toBe(200);
155
+ expect(serviceBlockInstance.dimensions.top).toBe(3);
156
+ expect(serviceBlockInstance.dimensions.left).toBe(6);
157
+ const uiBlockInstance = result.plan.spec.blocks[1];
158
+ expect(uiBlockInstance.name).toBe('ui');
159
+ expect(uiBlockInstance.dimensions.width).toBe(150);
160
+ expect(uiBlockInstance.dimensions.height).toBe(200);
161
+ expect(uiBlockInstance.dimensions.top).toBe(107);
162
+ expect(uiBlockInstance.dimensions.left).toBe(112);
163
+ expect(result.plan.spec.connections.length).toBe(1);
164
+ expect(result.plan.spec.connections[0].consumer.blockId).toBe(uiBlockInstance.id);
165
+ expect(result.plan.spec.connections[0].consumer.resourceName).toBe(clientResource?.metadata.name);
166
+ expect(result.plan.spec.connections[0].provider.blockId).toBe(serviceBlockInstance.id);
167
+ expect(result.plan.spec.connections[0].provider.resourceName).toBe(apiResource?.metadata.name);
168
+ });
169
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kapeta/local-cluster-service",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "Manages configuration, ports and service discovery for locally running Kapeta systems",
5
5
  "type": "commonjs",
6
6
  "exports": {
@@ -74,6 +74,8 @@
74
74
  "gunzip-maybe": "^1.4.2",
75
75
  "lodash": "^4.17.15",
76
76
  "md5": "2.2.1",
77
+ "ngraph.forcelayout": "^3.3.1",
78
+ "ngraph.graph": "^20.0.1",
77
79
  "node-cache": "^5.1.2",
78
80
  "node-uuid": "^1.4.8",
79
81
  "parse-data-uri": "^0.2.0",
@@ -3,14 +3,7 @@
3
3
  * SPDX-License-Identifier: BUSL-1.1
4
4
  */
5
5
 
6
- import {
7
- ScreenTemplate,
8
- StormBlockInfoFilled,
9
- StormBlockType,
10
- StormConnection,
11
- StormEvent,
12
- StormResourceType,
13
- } from './events';
6
+ import { StormBlockInfoFilled, StormBlockType, StormConnection, StormEvent, StormResourceType } from './events';
14
7
  import {
15
8
  BlockDefinition,
16
9
  BlockInstance,
@@ -24,6 +17,8 @@ import { KapetaURI, normalizeKapetaUri, parseKapetaUri } from '@kapeta/nodejs-ut
24
17
  import { KAPLANG_ID, KAPLANG_VERSION, RESTMethod } from '@kapeta/kaplang-core';
25
18
  import uuid from 'node-uuid';
26
19
  import { definitionsManager } from '../definitionsManager';
20
+ import createGraph from 'ngraph.graph';
21
+ import createLayout from 'ngraph.forcelayout';
27
22
 
28
23
  export interface BlockDefinitionInfo {
29
24
  uri: KapetaURI;
@@ -31,7 +26,7 @@ export interface BlockDefinitionInfo {
31
26
  aiName: string;
32
27
  }
33
28
 
34
- export interface ParsedResult {
29
+ export interface StormDefinitions {
35
30
  plan: Plan;
36
31
  blocks: BlockDefinitionInfo[];
37
32
  }
@@ -175,6 +170,8 @@ export async function resolveOptions(): Promise<StormOptions> {
175
170
  };
176
171
  }
177
172
 
173
+ const LAYOUT_MARGIN = 50;
174
+
178
175
  export class StormEventParser {
179
176
  private events: StormEvent[] = [];
180
177
  private planName: string = '';
@@ -196,7 +193,7 @@ export class StormEventParser {
196
193
  this.connections = [];
197
194
  }
198
195
 
199
- public addEvent(evt: StormEvent): void {
196
+ public addEvent(handle:string, evt: StormEvent): StormDefinitions {
200
197
  this.events.push(evt);
201
198
  switch (evt.type) {
202
199
  case 'CREATE_PLAN_PROPERTIES':
@@ -236,6 +233,8 @@ export class StormEventParser {
236
233
  case 'FILE':
237
234
  break;
238
235
  }
236
+
237
+ return this.toResult(handle);
239
238
  }
240
239
 
241
240
  public getEvents(): StormEvent[] {
@@ -250,15 +249,57 @@ export class StormEventParser {
250
249
  return this.error;
251
250
  }
252
251
 
253
- private applyLayoutToBlocks(result: ParsedResult): ParsedResult {
252
+ private applyLayoutToBlocks(result: StormDefinitions): StormDefinitions {
253
+ const graph = createGraph();
254
+ const blockInstances: { [key: string]: BlockInstance } = {};
255
+
256
+ result.plan.spec.blocks.forEach((block, index) => {
257
+ graph.addNode(block.id, block);
258
+ blockInstances[block.id] = block;
259
+ });
260
+
261
+ result.plan.spec.connections.forEach((connection) => {
262
+ graph.addLink(connection.provider.blockId, connection.consumer.blockId);
263
+ });
264
+
265
+ const layout = createLayout(graph, {
266
+ springLength: 150,
267
+ debug: true,
268
+ dimensions: 2,
269
+ gravity: 2,
270
+ springCoefficient: 0.0008,
271
+ });
272
+
273
+ for (let i = 0; i < 100; ++i) {
274
+ layout.step();
275
+ }
276
+
277
+ // Layout might place things in negative space. We move everything to positive space
278
+ const graphBox = layout.getGraphRect();
279
+ let yAdjust = 0;
280
+ let xAdjust = 0;
281
+ if (graphBox.y1 < 0) {
282
+ yAdjust = -graphBox.y1;
283
+ }
284
+ if (graphBox.x1 < 0) {
285
+ xAdjust = -graphBox.x1;
286
+ }
287
+
288
+ graph.forEachNode((node) => {
289
+ const position = layout.getNodePosition(node.id);
290
+ blockInstances[node.id].dimensions.left = LAYOUT_MARGIN + Math.round(position.x + xAdjust);
291
+ blockInstances[node.id].dimensions.top = LAYOUT_MARGIN + Math.round(position.y + yAdjust);
292
+ });
293
+
294
+ layout.dispose();
295
+
254
296
  return result;
255
297
  }
256
298
 
257
- public toResult(handle: string): ParsedResult {
299
+ public toResult(handle: string): StormDefinitions {
258
300
  const planRef = this.toRef(handle, this.planName);
259
301
  const blockDefinitions = this.toBlockDefinitions(handle);
260
302
  const refIdMap: { [key: string]: string } = {};
261
- const screens: { [key: string]: ScreenTemplate[] } = {};
262
303
  const blocks = Object.entries(blockDefinitions).map(([ref, block]) => {
263
304
  const id = uuid.v4();
264
305
  refIdMap[ref] = id;
@@ -271,7 +312,7 @@ export class StormEventParser {
271
312
  dimensions: {
272
313
  left: 0,
273
314
  top: 0,
274
- width: 200,
315
+ width: 150,
275
316
  height: 200,
276
317
  },
277
318
  } satisfies BlockInstance;
@@ -504,7 +545,7 @@ export class StormEventParser {
504
545
  } satisfies SourceCode,
505
546
  },
506
547
  };
507
- blockSpec.providers!.push(dbResource);
548
+ blockSpec.consumers!.push(dbResource);
508
549
  break;
509
550
  case 'JWTCONSUMER':
510
551
  case 'WEBFRAGMENT':
@@ -2,6 +2,7 @@
2
2
  * Copyright 2023 Kapeta Inc.
3
3
  * SPDX-License-Identifier: BUSL-1.1
4
4
  */
5
+ import {StormDefinitions} from "./event-parser";
5
6
 
6
7
  export type StormResourceType =
7
8
  | 'API'
@@ -154,6 +155,13 @@ export interface StormEventDone {
154
155
  created: number;
155
156
  }
156
157
 
158
+ export interface StormEventDefinitionChange {
159
+ type: 'DEFINITION_CHANGE';
160
+ reason: string;
161
+ created: number;
162
+ payload: StormDefinitions;
163
+ }
164
+
157
165
  export type StormEvent =
158
166
  | StormEventCreateBlock
159
167
  | StormEventCreateConnection
@@ -165,4 +173,5 @@ export type StormEvent =
165
173
  | StormEventScreen
166
174
  | StormEventScreenCandidate
167
175
  | StormEventFile
168
- | StormEventDone;
176
+ | StormEventDone
177
+ | StormEventDefinitionChange;
@@ -4,15 +4,15 @@
4
4
  */
5
5
 
6
6
  import Router from 'express-promise-router';
7
- import { Response } from 'express';
8
- import { corsHandler } from '../middleware/cors';
9
- import { stringBody } from '../middleware/stringBody';
10
- import { KapetaBodyRequest } from '../types';
11
- import { StormContextRequest, StormFileImplementationPrompt, StormFileInfo, StormStream } from './stream';
12
- import { stormClient } from './stormClient';
13
- import { StormEvent } from './events';
14
- import { resolveOptions, StormEventParser } from './event-parser';
15
- import { StormCodegen } from './codegen';
7
+ import {Response} from 'express';
8
+ import {corsHandler} from '../middleware/cors';
9
+ import {stringBody} from '../middleware/stringBody';
10
+ import {KapetaBodyRequest} from '../types';
11
+ import {StormContextRequest, StormFileImplementationPrompt, StormFileInfo, StormStream} from './stream';
12
+ import {stormClient} from './stormClient';
13
+ import {StormEvent} from './events';
14
+ import {resolveOptions, StormDefinitions, StormEventParser} from './event-parser';
15
+ import {StormCodegen} from './codegen';
16
16
 
17
17
  const router = Router();
18
18
 
@@ -33,24 +33,29 @@ router.post('/:handle/all', async (req: KapetaBodyRequest, res: Response) => {
33
33
  res.set('Content-Type', 'application/x-ndjson');
34
34
 
35
35
  metaStream.on('data', (data: StormEvent) => {
36
- eventParser.addEvent(data);
36
+ const result = eventParser.addEvent(req.params.handle, data);
37
+
38
+ sendDefinitions(res, result);
37
39
  });
38
40
 
39
41
  await streamStormPartialResponse(metaStream, res);
40
42
 
41
43
  if (!eventParser.isValid()) {
42
44
  // We can't continue if the meta stream is invalid
43
- res.write({
45
+ sendEvent(res, {
44
46
  type: 'ERROR_INTERNAL',
45
- payload: { error: eventParser.getError() },
47
+ payload: {error: eventParser.getError()},
46
48
  reason: 'Failed to generate system',
47
49
  created: Date.now(),
48
- } satisfies StormEvent);
50
+ });
49
51
  res.end();
50
52
  return;
51
53
  }
54
+
52
55
  const result = eventParser.toResult(handle);
53
56
 
57
+ sendDefinitions(res, result);
58
+
54
59
  const stormCodegen = new StormCodegen(aiRequest.prompt, result.blocks, eventParser.getEvents());
55
60
 
56
61
  const codegenStream = streamStormPartialResponse(stormCodegen.getStream(), res);
@@ -65,13 +70,20 @@ router.post('/:handle/all', async (req: KapetaBodyRequest, res: Response) => {
65
70
  }
66
71
  });
67
72
 
73
+ function sendDefinitions(res: Response, result: StormDefinitions) {
74
+ sendEvent(res, {
75
+ type: 'DEFINITION_CHANGE',
76
+ payload: result,
77
+ reason: 'Updates to definition',
78
+ created: Date.now(),
79
+ });
80
+ }
81
+
68
82
  function sendDone(res: Response) {
69
- res.write(
70
- JSON.stringify({
71
- type: 'DONE',
72
- created: Date.now(),
73
- } satisfies StormEvent) + '\n'
74
- );
83
+ sendEvent(res, {
84
+ type: 'DONE',
85
+ created: Date.now(),
86
+ });
75
87
 
76
88
  res.end();
77
89
  }
@@ -79,23 +91,22 @@ function sendDone(res: Response) {
79
91
  function sendError(err: Error, res: Response) {
80
92
  console.error('Failed to send prompt', err);
81
93
  if (res.headersSent) {
82
- res.write(
83
- JSON.stringify({
84
- type: 'ERROR_INTERNAL',
85
- created: Date.now(),
86
- payload: { error: err.message },
87
- reason: 'Failed while sending prompt',
88
- } satisfies StormEvent) + '\n'
89
- );
94
+ sendEvent(res, {
95
+ type: 'ERROR_INTERNAL',
96
+ created: Date.now(),
97
+ payload: {error: err.message},
98
+ reason: 'Failed while sending prompt',
99
+ });
90
100
  } else {
91
- res.status(400).send({ error: err.message });
101
+ res.status(400).send({error: err.message});
92
102
  }
93
103
  }
94
104
 
105
+
95
106
  function streamStormPartialResponse(result: StormStream, res: Response) {
96
107
  return new Promise<void>((resolve, reject) => {
97
108
  result.on('data', (data) => {
98
- res.write(JSON.stringify(data) + '\n');
109
+ sendEvent(res, data);
99
110
  });
100
111
 
101
112
  result.on('error', (err) => {
@@ -108,4 +119,8 @@ function streamStormPartialResponse(result: StormStream, res: Response) {
108
119
  });
109
120
  }
110
121
 
122
+ function sendEvent(res: Response, evt: StormEvent) {
123
+ res.write(JSON.stringify(evt) + '\n');
124
+ }
125
+
111
126
  export default router;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Copyright 2023 Kapeta Inc.
3
+ * SPDX-License-Identifier: BUSL-1.1
4
+ */
5
+
6
+ import { StormEventParser } from '../../src/storm/event-parser';
7
+ import { StormEvent } from '../../src/storm/events';
8
+
9
+ const parserOptions = {
10
+ serviceKind: 'kapeta/block-service:local',
11
+ serviceLanguage: 'kapeta/language-target-nodejs-ts:local',
12
+
13
+ frontendKind: 'kapeta/block-type-frontend:local',
14
+ frontendLanguage: 'kapeta/language-target-react-ts:local',
15
+
16
+ cliKind: 'kapeta/block-type-cli:local',
17
+ cliLanguage: 'kapeta/language-target-nodejs-ts:local',
18
+
19
+ desktopKind: 'kapeta/block-type-desktop:local',
20
+ desktopLanguage: 'kapeta/language-target-electron-ts:local',
21
+
22
+ gatewayKind: 'kapeta/block-type-gateway:local',
23
+
24
+ mqKind: 'kapeta/block-type-mq:local',
25
+ exchangeKind: 'kapeta/resource-type-exchange:local',
26
+ queueKind: 'kapeta/resource-type-queue:local',
27
+ publisherKind: 'kapeta/resource-type-publisher:local',
28
+ subscriberKind: 'kapeta/resource-type-subscriber:local',
29
+ databaseKind: 'kapeta/block-type-database:local',
30
+
31
+ apiKind: 'kapeta/block-type-api:local',
32
+ clientKind: 'kapeta/block-type-client:local',
33
+
34
+ webPageKind: 'kapeta/block-type-web-page:local',
35
+ webFragmentKind: 'kapeta/block-type-web-fragment:local',
36
+
37
+ jwtProviderKind: 'kapeta/resource-type-jwt-provider:local',
38
+ jwtConsumerKind: 'kapeta/resource-type-jwt-consumer:local',
39
+
40
+ smtpKind: 'kapeta/resource-type-smtp:local',
41
+ externalApiKind: 'kapeta/resource-type-external-api:local',
42
+ };
43
+
44
+ const events: StormEvent[] = [
45
+ {
46
+ type: 'CREATE_PLAN_PROPERTIES',
47
+ created: Date.now(),
48
+ reason: 'create plan properties',
49
+ payload: {
50
+ name: 'my-plan',
51
+ description: 'my plan description',
52
+ },
53
+ },
54
+ {
55
+ type: 'CREATE_BLOCK',
56
+ reason: 'create backend',
57
+ created: Date.now(),
58
+ payload: {
59
+ name: 'service',
60
+ description: 'A service block',
61
+ type: 'BACKEND',
62
+ resources: [
63
+ {
64
+ name: 'entities',
65
+ type: 'DATABASE',
66
+ description: 'A database resource',
67
+ },
68
+ {
69
+ type: 'API',
70
+ name: 'entities',
71
+ description: 'An API resource',
72
+ },
73
+ ],
74
+ },
75
+ },
76
+ {
77
+ type: 'CREATE_BLOCK',
78
+ reason: 'create frontend',
79
+ created: Date.now(),
80
+ payload: {
81
+ name: 'ui',
82
+ description: 'A frontend block',
83
+ type: 'FRONTEND',
84
+ resources: [
85
+ {
86
+ name: 'web',
87
+ type: 'WEBPAGE',
88
+ description: 'A web page',
89
+ },
90
+ {
91
+ type: 'CLIENT',
92
+ name: 'entities',
93
+ description: 'Client for backend',
94
+ },
95
+ ],
96
+ },
97
+ },
98
+ {
99
+ type: 'CREATE_CONNECTION',
100
+ created: Date.now(),
101
+ reason: 'connect service to ui',
102
+ payload: {
103
+ fromComponent: 'service',
104
+ fromResource: 'entities',
105
+ fromResourceType: 'API',
106
+ toComponent: 'ui',
107
+ toResource: 'entities',
108
+ toResourceType: 'CLIENT',
109
+ },
110
+ },
111
+ {
112
+ type: 'CREATE_API',
113
+ reason: 'create api',
114
+ created: Date.now(),
115
+ payload: {
116
+ blockName: 'service',
117
+ content: `controller Entities('/entities') {
118
+ @GET('/')
119
+ list(): string[]
120
+ }`,
121
+ },
122
+ },
123
+ {
124
+ type: 'CREATE_MODEL',
125
+ created: Date.now(),
126
+ reason: 'create model',
127
+ payload: {
128
+ blockName: 'service',
129
+ content: `type Entity {
130
+ @Id
131
+ id: string
132
+
133
+ name: string
134
+ }`,
135
+ },
136
+ },
137
+ ];
138
+
139
+ describe('event-parser', () => {
140
+ it('it can parse events into a plan and blocks with proper layout', () => {
141
+ const parser = new StormEventParser(parserOptions);
142
+ events.forEach((event) => parser.addEvent('kapeta', event));
143
+
144
+ const result = parser.toResult('kapeta');
145
+
146
+ expect(result.plan.metadata.name).toBe('kapeta/my-plan');
147
+ expect(result.plan.metadata.description).toBe('my plan description');
148
+ expect(result.blocks.length).toBe(2);
149
+ expect(result.blocks[0].content.metadata.name).toBe('kapeta/service');
150
+ expect(result.blocks[1].content.metadata.name).toBe('kapeta/ui');
151
+
152
+ const dbResource = result.blocks[0].content.spec.consumers?.[0];
153
+ const apiResource = result.blocks[0].content.spec.providers?.[0];
154
+ const clientResource = result.blocks[1].content.spec.consumers?.[0];
155
+ const pageResource = result.blocks[1].content.spec.providers?.[0];
156
+
157
+ expect(apiResource).toBeDefined();
158
+ expect(clientResource).toBeDefined();
159
+ expect(dbResource).toBeDefined();
160
+ expect(pageResource).toBeDefined();
161
+
162
+ expect(apiResource?.kind).toBe(parserOptions.apiKind);
163
+ expect(clientResource?.kind).toBe(parserOptions.clientKind);
164
+ expect(dbResource?.kind).toBe(parserOptions.databaseKind);
165
+ expect(pageResource?.kind).toBe(parserOptions.webPageKind);
166
+
167
+ expect(apiResource?.spec).toEqual(clientResource?.spec);
168
+ expect(dbResource?.spec.source.value).toContain('type Entity');
169
+
170
+ const serviceBlockInstance = result.plan.spec.blocks[0];
171
+ expect(serviceBlockInstance.name).toBe('service');
172
+ expect(serviceBlockInstance.dimensions.width).toBe(150);
173
+ expect(serviceBlockInstance.dimensions.height).toBe(200);
174
+ expect(serviceBlockInstance.dimensions.top).toBe(3);
175
+ expect(serviceBlockInstance.dimensions.left).toBe(6);
176
+
177
+ const uiBlockInstance = result.plan.spec.blocks[1];
178
+ expect(uiBlockInstance.name).toBe('ui');
179
+ expect(uiBlockInstance.dimensions.width).toBe(150);
180
+ expect(uiBlockInstance.dimensions.height).toBe(200);
181
+ expect(uiBlockInstance.dimensions.top).toBe(107);
182
+ expect(uiBlockInstance.dimensions.left).toBe(112);
183
+
184
+ expect(result.plan.spec.connections.length).toBe(1);
185
+ expect(result.plan.spec.connections[0].consumer.blockId).toBe(uiBlockInstance.id);
186
+ expect(result.plan.spec.connections[0].consumer.resourceName).toBe(clientResource?.metadata.name);
187
+ expect(result.plan.spec.connections[0].provider.blockId).toBe(serviceBlockInstance.id);
188
+ expect(result.plan.spec.connections[0].provider.resourceName).toBe(apiResource?.metadata.name);
189
+ });
190
+ });