flowspec-mcp 4.1.0 → 4.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.
Files changed (67) hide show
  1. package/README.md +4 -159
  2. package/dist/db.d.ts +0 -26
  3. package/dist/db.js +396 -318
  4. package/dist/db.js.map +1 -1
  5. package/dist/index.js +0 -0
  6. package/dist/server.js +7 -26
  7. package/dist/server.js.map +1 -1
  8. package/dist/tools/createEdge.d.ts +4 -4
  9. package/dist/tools/createNode.d.ts +2 -2
  10. package/dist/tools/deleteEdge.js +4 -20
  11. package/dist/tools/deleteEdge.js.map +1 -1
  12. package/dist/tools/deleteNode.js +4 -20
  13. package/dist/tools/deleteNode.js.map +1 -1
  14. package/dist/tools/updateEdge.d.ts +2 -2
  15. package/dist/tools/updateEdge.js +3 -19
  16. package/dist/tools/updateEdge.js.map +1 -1
  17. package/dist/tools/updateNode.d.ts +2 -2
  18. package/dist/tools/updateNode.js +5 -21
  19. package/dist/tools/updateNode.js.map +1 -1
  20. package/dist/tools/updateProject.d.ts +2 -2
  21. package/dist/types.d.ts +0 -12
  22. package/package.json +2 -2
  23. package/dist/analysis/analysisUtils.d.ts +0 -36
  24. package/dist/analysis/analysisUtils.js +0 -284
  25. package/dist/analysis/analysisUtils.js.map +0 -1
  26. package/dist/export/jsonExporter.d.ts +0 -17
  27. package/dist/export/jsonExporter.js +0 -176
  28. package/dist/export/jsonExporter.js.map +0 -1
  29. package/dist/layout/semanticLayout.d.ts +0 -24
  30. package/dist/layout/semanticLayout.js +0 -233
  31. package/dist/layout/semanticLayout.js.map +0 -1
  32. package/dist/resources/selection.d.ts +0 -5
  33. package/dist/resources/selection.js +0 -88
  34. package/dist/resources/selection.js.map +0 -1
  35. package/dist/tools/captureScreen.d.ts +0 -48
  36. package/dist/tools/captureScreen.js +0 -135
  37. package/dist/tools/captureScreen.js.map +0 -1
  38. package/dist/tools/createSubview.d.ts +0 -50
  39. package/dist/tools/createSubview.js +0 -29
  40. package/dist/tools/createSubview.js.map +0 -1
  41. package/dist/tools/deleteSubview.d.ts +0 -24
  42. package/dist/tools/deleteSubview.js +0 -19
  43. package/dist/tools/deleteSubview.js.map +0 -1
  44. package/dist/tools/generateSpec.d.ts +0 -26
  45. package/dist/tools/generateSpec.js +0 -336
  46. package/dist/tools/generateSpec.js.map +0 -1
  47. package/dist/tools/getJson.d.ts +0 -21
  48. package/dist/tools/getJson.js +0 -24
  49. package/dist/tools/getJson.js.map +0 -1
  50. package/dist/tools/healthCheck.d.ts +0 -8
  51. package/dist/tools/healthCheck.js +0 -16
  52. package/dist/tools/healthCheck.js.map +0 -1
  53. package/dist/tools/ingestCodebase.d.ts +0 -27
  54. package/dist/tools/ingestCodebase.js +0 -516
  55. package/dist/tools/ingestCodebase.js.map +0 -1
  56. package/dist/tools/listSubviews.d.ts +0 -21
  57. package/dist/tools/listSubviews.js +0 -34
  58. package/dist/tools/listSubviews.js.map +0 -1
  59. package/dist/tools/smartLayout.d.ts +0 -30
  60. package/dist/tools/smartLayout.js +0 -74
  61. package/dist/tools/smartLayout.js.map +0 -1
  62. package/dist/tools/updateSubview.d.ts +0 -53
  63. package/dist/tools/updateSubview.js +0 -33
  64. package/dist/tools/updateSubview.js.map +0 -1
  65. package/dist/utils/selectionHelper.d.ts +0 -61
  66. package/dist/utils/selectionHelper.js +0 -111
  67. package/dist/utils/selectionHelper.js.map +0 -1
package/dist/db.js CHANGED
@@ -13,6 +13,89 @@ if (MODE === 'cloud') {
13
13
  'Find your User ID at: https://flowspec.app/account (under "MCP Configuration").');
14
14
  sql = neon(DATABASE_URL);
15
15
  }
16
+ // ─── Helper: reconstruct canvas_state from normalized rows ──────────
17
+ function buildCanvasState(nodes, edges, screens, regions, regionElements) {
18
+ const canvasNodes = nodes.map(n => ({
19
+ id: n.id,
20
+ type: n.type,
21
+ position: { x: n.position_x, y: n.position_y },
22
+ data: { ...n.data, label: n.label ?? n.data.label }
23
+ }));
24
+ const canvasEdges = edges.map(e => ({
25
+ id: e.id,
26
+ source: e.source,
27
+ target: e.target,
28
+ type: e.type,
29
+ data: e.data
30
+ }));
31
+ const canvasScreens = screens.map(s => {
32
+ const screenRegions = regions
33
+ .filter(r => r.screen_id === s.id)
34
+ .map(r => {
35
+ const elementIds = regionElements
36
+ .filter(re => re.region_id === r.id)
37
+ .sort((a, b) => a.position_order - b.position_order)
38
+ .map(re => re.node_id);
39
+ return {
40
+ id: r.id,
41
+ label: r.label ?? undefined,
42
+ position: { x: r.position_x, y: r.position_y },
43
+ size: { width: r.size_width, height: r.size_height },
44
+ elementIds,
45
+ componentNodeId: r.component_node_id ?? undefined
46
+ };
47
+ });
48
+ return {
49
+ id: s.id,
50
+ name: s.name,
51
+ imageUrl: s.image_url,
52
+ imageWidth: s.image_width,
53
+ imageHeight: s.image_height,
54
+ imageFilename: s.image_filename ?? undefined,
55
+ regions: screenRegions
56
+ };
57
+ });
58
+ return {
59
+ nodes: canvasNodes,
60
+ edges: canvasEdges,
61
+ screens: canvasScreens.length > 0 ? canvasScreens : undefined
62
+ };
63
+ }
64
+ // ─── Helper: fetch full project from normalized tables (cloud) ──────
65
+ async function getProjectFromNormalized(projectId) {
66
+ const projectRows = await sql `
67
+ SELECT id, name, thumbnail_url, user_id, is_public, created_at, updated_at
68
+ FROM projects
69
+ WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
70
+ `;
71
+ if (projectRows.length === 0)
72
+ return null;
73
+ const meta = projectRows[0];
74
+ const [nodesRaw, edgesRaw, screensRaw, regionsRaw, regionElementsRaw] = await Promise.all([
75
+ sql `SELECT id, project_id, type, position_x, position_y, label, data FROM nodes WHERE project_id = ${projectId}`,
76
+ sql `SELECT id, project_id, source, target, type, data FROM edges WHERE project_id = ${projectId}`,
77
+ sql `SELECT id, project_id, name, image_url, local_image_path, image_filename, image_width, image_height FROM screens WHERE project_id = ${projectId}`,
78
+ sql `SELECT id, screen_id, project_id, label, position_x, position_y, size_width, size_height, component_node_id FROM screen_regions WHERE project_id = ${projectId}`,
79
+ sql `
80
+ SELECT re.region_id, re.node_id, re.position_order
81
+ FROM region_elements re
82
+ INNER JOIN screen_regions sr ON re.region_id = sr.id
83
+ WHERE sr.project_id = ${projectId}
84
+ ORDER BY re.region_id, re.position_order
85
+ `
86
+ ]);
87
+ const canvas_state = buildCanvasState(nodesRaw, edgesRaw, screensRaw, regionsRaw, regionElementsRaw);
88
+ return {
89
+ id: meta.id,
90
+ name: meta.name,
91
+ canvas_state,
92
+ thumbnail_url: meta.thumbnail_url ?? null,
93
+ user_id: meta.user_id,
94
+ is_public: meta.is_public,
95
+ created_at: meta.created_at,
96
+ updated_at: meta.updated_at
97
+ };
98
+ }
16
99
  // ─── Local mode (HTTP to desktop server) ───────────────────────────
17
100
  async function fetchLocal(path, options) {
18
101
  const token = getLocalAuthToken();
@@ -48,16 +131,10 @@ export async function getProject(projectId) {
48
131
  return null;
49
132
  return res.json();
50
133
  }
51
- const rows = await sql `
52
- SELECT id, name, canvas_state, thumbnail_url, user_id, is_public, created_at, updated_at
53
- FROM projects
54
- WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
55
- `;
56
- return rows[0] ?? null;
134
+ return getProjectFromNormalized(projectId);
57
135
  }
58
136
  export async function searchNodes(query, nodeType) {
59
137
  if (MODE === 'local') {
60
- // In local mode, fetch all projects and search client-side (same logic as cloud)
61
138
  const res = await fetchLocal('/api/projects');
62
139
  const summaries = await res.json();
63
140
  const results = [];
@@ -87,36 +164,27 @@ export async function searchNodes(query, nodeType) {
87
164
  }
88
165
  return results;
89
166
  }
167
+ // Cloud mode: indexed query on normalized nodes table
168
+ const typeFilter = nodeType ? nodeType : null;
90
169
  const rows = await sql `
91
- SELECT id, name, canvas_state
92
- FROM projects
93
- WHERE user_id = ${FLOWSPEC_USER_ID}
170
+ SELECT n.id AS node_id, n.type AS node_type, n.label,
171
+ p.id AS project_id, p.name AS project_name
172
+ FROM nodes n
173
+ INNER JOIN projects p ON n.project_id = p.id
174
+ WHERE p.user_id = ${FLOWSPEC_USER_ID}
175
+ AND n.type != 'image'
176
+ AND n.label ILIKE ${'%' + query + '%'}
177
+ AND (${typeFilter}::text IS NULL OR n.type = ${typeFilter})
94
178
  `;
95
- const results = [];
96
- const lowerQuery = query.toLowerCase();
97
- for (const row of rows) {
98
- const project = row;
99
- const nodes = project.canvas_state?.nodes ?? [];
100
- for (const node of nodes) {
101
- if (node.type === 'image')
102
- continue;
103
- if (nodeType && node.type !== nodeType)
104
- continue;
105
- const label = node.data?.label ?? '';
106
- if (label.toLowerCase().includes(lowerQuery)) {
107
- results.push({
108
- projectId: project.id,
109
- projectName: project.name,
110
- nodeId: node.id,
111
- nodeType: node.type,
112
- label,
113
- });
114
- }
115
- }
116
- }
117
- return results;
179
+ return rows.map(row => ({
180
+ projectId: row.project_id,
181
+ projectName: row.project_name,
182
+ nodeId: row.node_id,
183
+ nodeType: row.node_type,
184
+ label: row.label
185
+ }));
118
186
  }
119
- // ─── Write operations (local mode only for now) ────────────────────
187
+ // ─── Write operations ───────────────────────────────────────────────
120
188
  export async function createProjectViaApi(name, canvasState) {
121
189
  if (MODE === 'local') {
122
190
  const res = await fetchLocal('/api/projects', {
@@ -127,13 +195,36 @@ export async function createProjectViaApi(name, canvasState) {
127
195
  throw new Error(`Failed to create project: ${res.status}`);
128
196
  return res.json();
129
197
  }
130
- // Cloud mode: direct SQL insert
131
- const rows = await sql `
132
- INSERT INTO projects (name, canvas_state, user_id)
133
- VALUES (${name}, ${JSON.stringify(canvasState ?? { nodes: [], edges: [] })}, ${FLOWSPEC_USER_ID})
134
- RETURNING id, name, canvas_state, thumbnail_url, created_at, updated_at
198
+ // Cloud mode: insert project metadata, then decompose canvas_state into normalized tables
199
+ const projectId = randomUUID();
200
+ await sql `
201
+ INSERT INTO projects (id, name, user_id, created_at, updated_at)
202
+ VALUES (${projectId}, ${name}, ${FLOWSPEC_USER_ID}, NOW(), NOW())
135
203
  `;
136
- return rows[0];
204
+ // If canvas_state provided, decompose into normalized tables
205
+ const cs = (canvasState ?? { nodes: [], edges: [] });
206
+ if (cs.nodes && cs.nodes.length > 0) {
207
+ for (const node of cs.nodes) {
208
+ const nodeId = node.id ?? randomUUID();
209
+ await sql `
210
+ INSERT INTO nodes (id, project_id, type, position_x, position_y, label, data, created_at, updated_at)
211
+ VALUES (${nodeId}, ${projectId}, ${node.type ?? 'datapoint'}, ${node.position?.x ?? 0}, ${node.position?.y ?? 0},
212
+ ${node.data?.label ?? null}, ${JSON.stringify(node.data ?? {})}::jsonb, NOW(), NOW())
213
+ `;
214
+ }
215
+ }
216
+ if (cs.edges && cs.edges.length > 0) {
217
+ for (const edge of cs.edges) {
218
+ const edgeId = edge.id ?? randomUUID();
219
+ await sql `
220
+ INSERT INTO edges (id, project_id, source, target, type, data, created_at, updated_at)
221
+ VALUES (${edgeId}, ${projectId}, ${edge.source}, ${edge.target},
222
+ ${edge.data?.edgeType ?? edge.type ?? 'flows-to'}, ${JSON.stringify(edge.data ?? {})}::jsonb, NOW(), NOW())
223
+ `;
224
+ }
225
+ }
226
+ const project = await getProjectFromNormalized(projectId);
227
+ return project;
137
228
  }
138
229
  export async function updateProjectViaApi(projectId, updates) {
139
230
  if (MODE === 'local') {
@@ -145,17 +236,75 @@ export async function updateProjectViaApi(projectId, updates) {
145
236
  return null;
146
237
  return res.json();
147
238
  }
148
- const name = updates.name;
149
- const canvasState = updates.canvas_state;
150
- const rows = await sql `
151
- UPDATE projects
152
- SET name = COALESCE(${name ?? null}, name),
153
- canvas_state = COALESCE(${canvasState ? JSON.stringify(canvasState) : null}::jsonb, canvas_state),
154
- updated_at = NOW()
155
- WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
156
- RETURNING id, name, canvas_state, thumbnail_url, created_at, updated_at
157
- `;
158
- return rows[0] ?? null;
239
+ // Cloud mode: update name if provided
240
+ if (updates.name) {
241
+ await sql `
242
+ UPDATE projects SET name = ${updates.name}, updated_at = NOW()
243
+ WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
244
+ `;
245
+ }
246
+ // If canvas_state provided, replace all entities
247
+ if (updates.canvas_state) {
248
+ const cs = updates.canvas_state;
249
+ // Delete existing entities (cascade handles region_elements)
250
+ await sql `DELETE FROM nodes WHERE project_id = ${projectId}`;
251
+ await sql `DELETE FROM edges WHERE project_id = ${projectId}`;
252
+ await sql `DELETE FROM screens WHERE project_id = ${projectId}`;
253
+ // Re-insert nodes
254
+ if (cs.nodes) {
255
+ for (const node of cs.nodes) {
256
+ const nodeId = node.id ?? randomUUID();
257
+ await sql `
258
+ INSERT INTO nodes (id, project_id, type, position_x, position_y, label, data, created_at, updated_at)
259
+ VALUES (${nodeId}, ${projectId}, ${node.type ?? 'datapoint'}, ${node.position?.x ?? 0}, ${node.position?.y ?? 0},
260
+ ${node.data?.label ?? null}, ${JSON.stringify(node.data ?? {})}::jsonb, NOW(), NOW())
261
+ `;
262
+ }
263
+ }
264
+ // Re-insert edges
265
+ if (cs.edges) {
266
+ for (const edge of cs.edges) {
267
+ const edgeId = edge.id ?? randomUUID();
268
+ await sql `
269
+ INSERT INTO edges (id, project_id, source, target, type, data, created_at, updated_at)
270
+ VALUES (${edgeId}, ${projectId}, ${edge.source}, ${edge.target},
271
+ ${edge.data?.edgeType ?? edge.type ?? 'flows-to'}, ${JSON.stringify(edge.data ?? {})}::jsonb, NOW(), NOW())
272
+ `;
273
+ }
274
+ }
275
+ // Re-insert screens + regions + region_elements
276
+ if (cs.screens) {
277
+ for (const screen of cs.screens) {
278
+ const screenId = screen.id ?? randomUUID();
279
+ await sql `
280
+ INSERT INTO screens (id, project_id, name, image_url, image_filename, image_width, image_height, created_at, updated_at)
281
+ VALUES (${screenId}, ${projectId}, ${screen.name}, ${screen.imageUrl ?? ''}, ${screen.imageFilename ?? null},
282
+ ${screen.imageWidth ?? 0}, ${screen.imageHeight ?? 0}, NOW(), NOW())
283
+ `;
284
+ if (screen.regions) {
285
+ for (const region of screen.regions) {
286
+ const regionId = region.id ?? randomUUID();
287
+ await sql `
288
+ INSERT INTO screen_regions (id, screen_id, project_id, label, position_x, position_y, size_width, size_height, component_node_id, created_at, updated_at)
289
+ VALUES (${regionId}, ${screenId}, ${projectId}, ${region.label ?? null},
290
+ ${region.position?.x ?? 0}, ${region.position?.y ?? 0},
291
+ ${region.size?.width ?? 0}, ${region.size?.height ?? 0},
292
+ ${region.componentNodeId ?? null}, NOW(), NOW())
293
+ `;
294
+ if (region.elementIds) {
295
+ for (let i = 0; i < region.elementIds.length; i++) {
296
+ await sql `
297
+ INSERT INTO region_elements (region_id, node_id, position_order, created_at)
298
+ VALUES (${regionId}, ${region.elementIds[i]}, ${i}, NOW())
299
+ `;
300
+ }
301
+ }
302
+ }
303
+ }
304
+ }
305
+ }
306
+ }
307
+ return getProjectFromNormalized(projectId);
159
308
  }
160
309
  export async function deleteProjectViaApi(projectId) {
161
310
  if (MODE === 'local') {
@@ -179,19 +328,18 @@ export async function createNodeViaApi(projectId, node) {
179
328
  return null;
180
329
  return res.json();
181
330
  }
182
- // Cloud mode: read-modify-write on canvas_state
183
- const project = await getProject(projectId);
184
- if (!project)
331
+ // Verify project ownership
332
+ const check = await sql `SELECT id FROM projects WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}`;
333
+ if (check.length === 0)
185
334
  return null;
186
335
  const nodeId = randomUUID();
187
- const newNode = { id: nodeId, ...node };
188
- project.canvas_state.nodes.push(newNode);
336
+ const label = node.data.label ?? null;
189
337
  await sql `
190
- UPDATE projects
191
- SET canvas_state = ${JSON.stringify(project.canvas_state)}::jsonb, updated_at = NOW()
192
- WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
338
+ INSERT INTO nodes (id, project_id, type, position_x, position_y, label, data, created_at, updated_at)
339
+ VALUES (${nodeId}, ${projectId}, ${node.type}, ${node.position.x}, ${node.position.y},
340
+ ${label}, ${JSON.stringify(node.data)}::jsonb, NOW(), NOW())
193
341
  `;
194
- return newNode;
342
+ return { id: nodeId, type: node.type, position: node.position, data: node.data };
195
343
  }
196
344
  export async function updateNodeViaApi(projectId, nodeId, updates) {
197
345
  if (MODE === 'local') {
@@ -203,41 +351,47 @@ export async function updateNodeViaApi(projectId, nodeId, updates) {
203
351
  return null;
204
352
  return res.json();
205
353
  }
206
- const project = await getProject(projectId);
207
- if (!project)
208
- return null;
209
- const idx = project.canvas_state.nodes.findIndex((n) => n.id === nodeId);
210
- if (idx === -1)
354
+ // Fetch current node
355
+ const nodeRows = await sql `
356
+ SELECT id, type, position_x, position_y, label, data
357
+ FROM nodes WHERE id = ${nodeId} AND project_id = ${projectId}
358
+ `;
359
+ if (nodeRows.length === 0)
211
360
  return null;
212
- const existing = project.canvas_state.nodes[idx];
213
- const updated = { ...existing, ...updates, id: nodeId, data: { ...existing.data, ...(updates.data ?? {}) } };
214
- project.canvas_state.nodes[idx] = updated;
361
+ const existing = nodeRows[0];
362
+ const existingData = existing.data;
363
+ const updatedData = updates.data
364
+ ? { ...existingData, ...updates.data }
365
+ : existingData;
366
+ const position = updates.position;
367
+ const label = updatedData.label ?? existing.label;
215
368
  await sql `
216
- UPDATE projects
217
- SET canvas_state = ${JSON.stringify(project.canvas_state)}::jsonb, updated_at = NOW()
218
- WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
369
+ UPDATE nodes
370
+ SET position_x = COALESCE(${position?.x ?? null}, position_x),
371
+ position_y = COALESCE(${position?.y ?? null}, position_y),
372
+ label = ${label},
373
+ data = ${JSON.stringify(updatedData)}::jsonb,
374
+ updated_at = NOW()
375
+ WHERE id = ${nodeId} AND project_id = ${projectId}
219
376
  `;
220
- return updated;
377
+ return {
378
+ id: nodeId,
379
+ type: updates.type ?? existing.type,
380
+ position: position ?? { x: existing.position_x, y: existing.position_y },
381
+ data: updatedData
382
+ };
221
383
  }
222
384
  export async function deleteNodeViaApi(projectId, nodeId) {
223
385
  if (MODE === 'local') {
224
386
  const res = await fetchLocal(`/api/projects/${projectId}/nodes/${nodeId}`, { method: 'DELETE' });
225
387
  return res.ok;
226
388
  }
227
- const project = await getProject(projectId);
228
- if (!project)
229
- return false;
230
- const idx = project.canvas_state.nodes.findIndex((n) => n.id === nodeId);
231
- if (idx === -1)
232
- return false;
233
- project.canvas_state.nodes.splice(idx, 1);
234
- project.canvas_state.edges = project.canvas_state.edges.filter((e) => e.source !== nodeId && e.target !== nodeId);
235
- await sql `
236
- UPDATE projects
237
- SET canvas_state = ${JSON.stringify(project.canvas_state)}::jsonb, updated_at = NOW()
238
- WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
389
+ // Edges cascade-delete via FK, so just delete the node
390
+ const rows = await sql `
391
+ DELETE FROM nodes WHERE id = ${nodeId} AND project_id = ${projectId}
392
+ RETURNING id
239
393
  `;
240
- return true;
394
+ return rows.length > 0;
241
395
  }
242
396
  export async function createEdgeViaApi(projectId, edge) {
243
397
  if (MODE === 'local') {
@@ -249,37 +403,29 @@ export async function createEdgeViaApi(projectId, edge) {
249
403
  return null;
250
404
  return res.json();
251
405
  }
252
- const project = await getProject(projectId);
253
- if (!project)
406
+ // Verify project ownership
407
+ const check = await sql `SELECT id FROM projects WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}`;
408
+ if (check.length === 0)
254
409
  return null;
255
410
  const edgeId = randomUUID();
256
- const newEdge = { id: edgeId, source: edge.source, target: edge.target, type: edge.type ?? 'typed', data: edge.data ?? {} };
257
- project.canvas_state.edges.push(newEdge);
411
+ const edgeType = edge.data?.edgeType ?? edge.type ?? 'flows-to';
412
+ const data = edge.data ?? {};
258
413
  await sql `
259
- UPDATE projects
260
- SET canvas_state = ${JSON.stringify(project.canvas_state)}::jsonb, updated_at = NOW()
261
- WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
414
+ INSERT INTO edges (id, project_id, source, target, type, data, created_at, updated_at)
415
+ VALUES (${edgeId}, ${projectId}, ${edge.source}, ${edge.target}, ${edgeType}, ${JSON.stringify(data)}::jsonb, NOW(), NOW())
262
416
  `;
263
- return newEdge;
417
+ return { id: edgeId, source: edge.source, target: edge.target, type: edgeType, data };
264
418
  }
265
419
  export async function deleteEdgeViaApi(projectId, edgeId) {
266
420
  if (MODE === 'local') {
267
421
  const res = await fetchLocal(`/api/projects/${projectId}/edges/${edgeId}`, { method: 'DELETE' });
268
422
  return res.ok;
269
423
  }
270
- const project = await getProject(projectId);
271
- if (!project)
272
- return false;
273
- const idx = project.canvas_state.edges.findIndex((e) => e.id === edgeId);
274
- if (idx === -1)
275
- return false;
276
- project.canvas_state.edges.splice(idx, 1);
277
- await sql `
278
- UPDATE projects
279
- SET canvas_state = ${JSON.stringify(project.canvas_state)}::jsonb, updated_at = NOW()
280
- WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
424
+ const rows = await sql `
425
+ DELETE FROM edges WHERE id = ${edgeId} AND project_id = ${projectId}
426
+ RETURNING id
281
427
  `;
282
- return true;
428
+ return rows.length > 0;
283
429
  }
284
430
  // ─── v3.0 API functions ────────────────────────────────────────────
285
431
  export async function cloneProjectViaApi(projectId) {
@@ -290,15 +436,12 @@ export async function cloneProjectViaApi(projectId) {
290
436
  const data = await res.json();
291
437
  return data.id;
292
438
  }
293
- const project = await getProject(projectId);
439
+ const project = await getProjectFromNormalized(projectId);
294
440
  if (!project)
295
441
  return null;
296
- const rows = await sql `
297
- INSERT INTO projects (name, canvas_state, user_id)
298
- VALUES (${project.name + ' (Copy)'}, ${JSON.stringify(project.canvas_state)}, ${FLOWSPEC_USER_ID})
299
- RETURNING id
300
- `;
301
- return rows[0].id;
442
+ // Create new project with cloned canvas_state
443
+ const newProject = await createProjectViaApi(project.name + ' (Copy)', project.canvas_state);
444
+ return newProject.id;
302
445
  }
303
446
  export async function uploadImageViaApi(base64Data, filename, contentType) {
304
447
  if (MODE !== 'local') {
@@ -322,27 +465,15 @@ export async function createScreenViaApi(projectId, name, imageUrl, imageWidth,
322
465
  return null;
323
466
  return res.json();
324
467
  }
325
- const project = await getProject(projectId);
326
- if (!project)
468
+ // Verify project ownership
469
+ const check = await sql `SELECT id FROM projects WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}`;
470
+ if (check.length === 0)
327
471
  return null;
328
472
  const screenId = randomUUID();
329
- const newScreen = {
330
- id: screenId,
331
- name,
332
- imageUrl: imageUrl ?? null,
333
- imageWidth: imageWidth ?? null,
334
- imageHeight: imageHeight ?? null,
335
- imageFilename: imageFilename ?? null,
336
- regions: [],
337
- };
338
- if (!project.canvas_state.screens) {
339
- project.canvas_state.screens = [];
340
- }
341
- project.canvas_state.screens.push(newScreen);
342
473
  await sql `
343
- UPDATE projects
344
- SET canvas_state = ${JSON.stringify(project.canvas_state)}::jsonb, updated_at = NOW()
345
- WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
474
+ INSERT INTO screens (id, project_id, name, image_url, image_filename, image_width, image_height, created_at, updated_at)
475
+ VALUES (${screenId}, ${projectId}, ${name}, ${imageUrl ?? ''}, ${imageFilename ?? null},
476
+ ${imageWidth ?? 0}, ${imageHeight ?? 0}, NOW(), NOW())
346
477
  `;
347
478
  return { id: screenId, name };
348
479
  }
@@ -356,38 +487,35 @@ export async function updateScreenViaApi(projectId, screenId, updates) {
356
487
  return null;
357
488
  return res.json();
358
489
  }
359
- const project = await getProject(projectId);
360
- if (!project || !project.canvas_state.screens)
361
- return null;
362
- const screen = project.canvas_state.screens.find((s) => s.id === screenId);
363
- if (!screen)
490
+ // Verify screen exists in project
491
+ const screenRows = await sql `
492
+ SELECT id, name FROM screens WHERE id = ${screenId} AND project_id = ${projectId}
493
+ `;
494
+ if (screenRows.length === 0)
364
495
  return null;
365
- Object.assign(screen, updates);
366
496
  await sql `
367
- UPDATE projects
368
- SET canvas_state = ${JSON.stringify(project.canvas_state)}::jsonb, updated_at = NOW()
369
- WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
497
+ UPDATE screens
498
+ SET name = COALESCE(${updates.name ?? null}, name),
499
+ image_url = COALESCE(${updates.imageUrl ?? null}, image_url),
500
+ image_width = COALESCE(${updates.imageWidth ?? null}, image_width),
501
+ image_height = COALESCE(${updates.imageHeight ?? null}, image_height),
502
+ updated_at = NOW()
503
+ WHERE id = ${screenId} AND project_id = ${projectId}
370
504
  `;
371
- return { id: screenId, name: screen.name };
505
+ const updatedName = updates.name ?? screenRows[0].name;
506
+ return { id: screenId, name: updatedName };
372
507
  }
373
508
  export async function deleteScreenViaApi(projectId, screenId) {
374
509
  if (MODE === 'local') {
375
510
  const res = await fetchLocal(`/api/projects/${projectId}/screens/${screenId}`, { method: 'DELETE' });
376
511
  return res.ok;
377
512
  }
378
- const project = await getProject(projectId);
379
- if (!project || !project.canvas_state.screens)
380
- return false;
381
- const idx = project.canvas_state.screens.findIndex((s) => s.id === screenId);
382
- if (idx === -1)
383
- return false;
384
- project.canvas_state.screens.splice(idx, 1);
385
- await sql `
386
- UPDATE projects
387
- SET canvas_state = ${JSON.stringify(project.canvas_state)}::jsonb, updated_at = NOW()
388
- WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
513
+ // Cascade deletes regions and region_elements via FK
514
+ const rows = await sql `
515
+ DELETE FROM screens WHERE id = ${screenId} AND project_id = ${projectId}
516
+ RETURNING id
389
517
  `;
390
- return true;
518
+ return rows.length > 0;
391
519
  }
392
520
  export async function addRegionViaApi(projectId, screenId, region) {
393
521
  if (MODE === 'local') {
@@ -399,29 +527,29 @@ export async function addRegionViaApi(projectId, screenId, region) {
399
527
  return null;
400
528
  return res.json();
401
529
  }
402
- const project = await getProject(projectId);
403
- if (!project || !project.canvas_state.screens)
404
- return null;
405
- const screen = project.canvas_state.screens.find((s) => s.id === screenId);
406
- if (!screen)
530
+ // Verify screen exists
531
+ const screenCheck = await sql `
532
+ SELECT id FROM screens WHERE id = ${screenId} AND project_id = ${projectId}
533
+ `;
534
+ if (screenCheck.length === 0)
407
535
  return null;
408
536
  const regionId = randomUUID();
409
- const newRegion = {
410
- id: regionId,
411
- label: region.label ?? null,
412
- position: region.position,
413
- size: region.size,
414
- elementIds: region.elementIds ?? [],
415
- componentNodeId: region.componentNodeId ?? null,
416
- };
417
- if (!screen.regions)
418
- screen.regions = [];
419
- screen.regions.push(newRegion);
420
537
  await sql `
421
- UPDATE projects
422
- SET canvas_state = ${JSON.stringify(project.canvas_state)}::jsonb, updated_at = NOW()
423
- WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
538
+ INSERT INTO screen_regions (id, screen_id, project_id, label, position_x, position_y, size_width, size_height, component_node_id, created_at, updated_at)
539
+ VALUES (${regionId}, ${screenId}, ${projectId}, ${region.label ?? null},
540
+ ${region.position.x}, ${region.position.y},
541
+ ${region.size.width}, ${region.size.height},
542
+ ${region.componentNodeId ?? null}, NOW(), NOW())
424
543
  `;
544
+ // Insert element references
545
+ if (region.elementIds && region.elementIds.length > 0) {
546
+ for (let i = 0; i < region.elementIds.length; i++) {
547
+ await sql `
548
+ INSERT INTO region_elements (region_id, node_id, position_order, created_at)
549
+ VALUES (${regionId}, ${region.elementIds[i]}, ${i}, NOW())
550
+ `;
551
+ }
552
+ }
425
553
  return { id: regionId, label: region.label };
426
554
  }
427
555
  export async function updateRegionViaApi(projectId, screenId, regionId, updates) {
@@ -434,21 +562,33 @@ export async function updateRegionViaApi(projectId, screenId, regionId, updates)
434
562
  return null;
435
563
  return res.json();
436
564
  }
437
- const project = await getProject(projectId);
438
- if (!project || !project.canvas_state.screens)
439
- return null;
440
- const screen = project.canvas_state.screens.find((s) => s.id === screenId);
441
- if (!screen || !screen.regions)
442
- return null;
443
- const region = screen.regions.find((r) => r.id === regionId);
444
- if (!region)
565
+ // Verify region exists
566
+ const regionCheck = await sql `
567
+ SELECT id FROM screen_regions WHERE id = ${regionId} AND screen_id = ${screenId} AND project_id = ${projectId}
568
+ `;
569
+ if (regionCheck.length === 0)
445
570
  return null;
446
- Object.assign(region, updates);
571
+ // Update region fields
447
572
  await sql `
448
- UPDATE projects
449
- SET canvas_state = ${JSON.stringify(project.canvas_state)}::jsonb, updated_at = NOW()
450
- WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
573
+ UPDATE screen_regions
574
+ SET label = COALESCE(${updates.label ?? null}, label),
575
+ position_x = COALESCE(${updates.position?.x ?? null}, position_x),
576
+ position_y = COALESCE(${updates.position?.y ?? null}, position_y),
577
+ size_width = COALESCE(${updates.size?.width ?? null}, size_width),
578
+ size_height = COALESCE(${updates.size?.height ?? null}, size_height),
579
+ updated_at = NOW()
580
+ WHERE id = ${regionId}
451
581
  `;
582
+ // Replace element IDs if provided
583
+ if (updates.elementIds !== undefined) {
584
+ await sql `DELETE FROM region_elements WHERE region_id = ${regionId}`;
585
+ for (let i = 0; i < updates.elementIds.length; i++) {
586
+ await sql `
587
+ INSERT INTO region_elements (region_id, node_id, position_order, created_at)
588
+ VALUES (${regionId}, ${updates.elementIds[i]}, ${i}, NOW())
589
+ `;
590
+ }
591
+ }
452
592
  return { id: regionId };
453
593
  }
454
594
  export async function removeRegionViaApi(projectId, screenId, regionId) {
@@ -456,22 +596,12 @@ export async function removeRegionViaApi(projectId, screenId, regionId) {
456
596
  const res = await fetchLocal(`/api/projects/${projectId}/screens/${screenId}/regions/${regionId}`, { method: 'DELETE' });
457
597
  return res.ok;
458
598
  }
459
- const project = await getProject(projectId);
460
- if (!project || !project.canvas_state.screens)
461
- return false;
462
- const screen = project.canvas_state.screens.find((s) => s.id === screenId);
463
- if (!screen || !screen.regions)
464
- return false;
465
- const idx = screen.regions.findIndex((r) => r.id === regionId);
466
- if (idx === -1)
467
- return false;
468
- screen.regions.splice(idx, 1);
469
- await sql `
470
- UPDATE projects
471
- SET canvas_state = ${JSON.stringify(project.canvas_state)}::jsonb, updated_at = NOW()
472
- WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
599
+ // Cascade deletes region_elements via FK
600
+ const rows = await sql `
601
+ DELETE FROM screen_regions WHERE id = ${regionId} AND screen_id = ${screenId} AND project_id = ${projectId}
602
+ RETURNING id
473
603
  `;
474
- return true;
604
+ return rows.length > 0;
475
605
  }
476
606
  export async function updateEdgeViaApi(projectId, edgeId, updates) {
477
607
  if (MODE === 'local') {
@@ -483,22 +613,22 @@ export async function updateEdgeViaApi(projectId, edgeId, updates) {
483
613
  return null;
484
614
  return res.json();
485
615
  }
486
- const project = await getProject(projectId);
487
- if (!project)
488
- return null;
489
- const edge = project.canvas_state.edges.find((e) => e.id === edgeId);
490
- if (!edge)
616
+ // Verify edge exists
617
+ const edgeRows = await sql `
618
+ SELECT id, data FROM edges WHERE id = ${edgeId} AND project_id = ${projectId}
619
+ `;
620
+ if (edgeRows.length === 0)
491
621
  return null;
492
- Object.assign(edge, updates);
493
- if (updates.label !== undefined) {
494
- if (!edge.data)
495
- edge.data = {};
496
- edge.data.label = updates.label;
497
- }
622
+ const existingData = (edgeRows[0].data ?? {});
623
+ const newData = { ...existingData };
624
+ if (updates.label !== undefined)
625
+ newData.label = updates.label;
498
626
  await sql `
499
- UPDATE projects
500
- SET canvas_state = ${JSON.stringify(project.canvas_state)}::jsonb, updated_at = NOW()
501
- WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
627
+ UPDATE edges
628
+ SET type = COALESCE(${updates.type ?? null}, type),
629
+ data = ${JSON.stringify(newData)}::jsonb,
630
+ updated_at = NOW()
631
+ WHERE id = ${edgeId} AND project_id = ${projectId}
502
632
  `;
503
633
  return { id: edgeId };
504
634
  }
@@ -512,121 +642,69 @@ export async function bulkImportCanvasState(projectId, canvasState, merge) {
512
642
  throw new Error(`Failed to import: ${res.status}`);
513
643
  return res.json();
514
644
  }
515
- const project = await getProject(projectId);
516
- if (!project)
645
+ // Verify project ownership
646
+ const check = await sql `SELECT id FROM projects WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}`;
647
+ if (check.length === 0)
517
648
  throw new Error('Project not found');
518
- if (merge) {
519
- // Merge mode: add new nodes/edges/screens
520
- project.canvas_state.nodes.push(...canvasState.nodes);
521
- project.canvas_state.edges.push(...canvasState.edges);
522
- if (canvasState.screens) {
523
- if (!project.canvas_state.screens)
524
- project.canvas_state.screens = [];
525
- project.canvas_state.screens.push(...canvasState.screens);
526
- }
527
- }
528
- else {
529
- // Replace mode: replace entire canvas state
530
- project.canvas_state = canvasState;
531
- }
532
- await sql `
533
- UPDATE projects
534
- SET canvas_state = ${JSON.stringify(project.canvas_state)}::jsonb, updated_at = NOW()
535
- WHERE id = ${projectId} AND user_id = ${FLOWSPEC_USER_ID}
536
- `;
537
- return {
538
- nodeCount: canvasState.nodes.length,
539
- edgeCount: canvasState.edges.length,
540
- screenCount: canvasState.screens?.length ?? 0,
541
- };
542
- }
543
- // ─── v4.1 Subview operations ───────────────────────────────────
544
- export async function createSubviewViaApi(projectId, name, description, nodePositions = []) {
545
- if (MODE === 'local') {
546
- const res = await fetchLocal(`/api/projects/${projectId}/subviews`, {
547
- method: 'POST',
548
- body: JSON.stringify({ name, description, nodePositions }),
549
- });
550
- if (!res.ok)
551
- return null;
552
- return res.json();
649
+ if (!merge) {
650
+ // Replace mode: clear existing entities first
651
+ await sql `DELETE FROM nodes WHERE project_id = ${projectId}`;
652
+ await sql `DELETE FROM edges WHERE project_id = ${projectId}`;
653
+ await sql `DELETE FROM screens WHERE project_id = ${projectId}`;
553
654
  }
554
- // Cloud mode: direct SQL insert
555
- const subviewId = randomUUID();
556
- await sql `
557
- INSERT INTO subviews (id, project_id, name, description)
558
- VALUES (${subviewId}, ${projectId}, ${name}, ${description ?? null})
559
- `;
560
- if (nodePositions.length > 0) {
561
- const subviewIds = nodePositions.map(() => subviewId);
562
- const nodeIds = nodePositions.map((np) => np.nodeId);
563
- const xPositions = nodePositions.map((np) => np.x);
564
- const yPositions = nodePositions.map((np) => np.y);
655
+ // Insert nodes
656
+ for (const node of canvasState.nodes) {
657
+ const nodeId = node.id ?? randomUUID();
565
658
  await sql `
566
- INSERT INTO subview_nodes (subview_id, node_id, position_x, position_y)
567
- SELECT * FROM UNNEST(
568
- ${subviewIds}::text[],
569
- ${nodeIds}::text[],
570
- ${xPositions}::real[],
571
- ${yPositions}::real[]
572
- )
659
+ INSERT INTO nodes (id, project_id, type, position_x, position_y, label, data, created_at, updated_at)
660
+ VALUES (${nodeId}, ${projectId}, ${node.type ?? 'datapoint'}, ${node.position?.x ?? 0}, ${node.position?.y ?? 0},
661
+ ${node.data?.label ?? null}, ${JSON.stringify(node.data ?? {})}::jsonb, NOW(), NOW())
573
662
  `;
574
663
  }
575
- return { id: subviewId, name, description, nodePositions };
576
- }
577
- export async function updateSubviewViaApi(projectId, subviewId, updates) {
578
- if (MODE === 'local') {
579
- const res = await fetchLocal(`/api/projects/${projectId}/subviews/${subviewId}`, {
580
- method: 'PATCH',
581
- body: JSON.stringify(updates),
582
- });
583
- if (!res.ok)
584
- return null;
585
- return res.json();
664
+ // Insert edges
665
+ for (const edge of canvasState.edges) {
666
+ const edgeId = edge.id ?? randomUUID();
667
+ await sql `
668
+ INSERT INTO edges (id, project_id, source, target, type, data, created_at, updated_at)
669
+ VALUES (${edgeId}, ${projectId}, ${edge.source}, ${edge.target},
670
+ ${edge.data?.edgeType ?? edge.type ?? 'flows-to'}, ${JSON.stringify(edge.data ?? {})}::jsonb, NOW(), NOW())
671
+ `;
586
672
  }
587
- // Cloud mode: direct SQL update
588
- const name = updates.name;
589
- const description = updates.description;
590
- await sql `
591
- UPDATE subviews
592
- SET name = COALESCE(${name ?? null}, name),
593
- description = COALESCE(${description ?? null}, description),
594
- updated_at = NOW()
595
- WHERE id = ${subviewId}
596
- `;
597
- if (updates.nodePositions !== undefined) {
598
- // Delete existing positions
599
- await sql `DELETE FROM subview_nodes WHERE subview_id = ${subviewId}`;
600
- // Insert new positions
601
- if (updates.nodePositions.length > 0) {
602
- const subviewIds = updates.nodePositions.map(() => subviewId);
603
- const nodeIds = updates.nodePositions.map((np) => np.nodeId);
604
- const xPositions = updates.nodePositions.map((np) => np.x);
605
- const yPositions = updates.nodePositions.map((np) => np.y);
673
+ // Insert screens + regions
674
+ if (canvasState.screens) {
675
+ for (const screen of canvasState.screens) {
676
+ const screenId = screen.id ?? randomUUID();
606
677
  await sql `
607
- INSERT INTO subview_nodes (subview_id, node_id, position_x, position_y)
608
- SELECT * FROM UNNEST(
609
- ${subviewIds}::text[],
610
- ${nodeIds}::text[],
611
- ${xPositions}::real[],
612
- ${yPositions}::real[]
613
- )
678
+ INSERT INTO screens (id, project_id, name, image_url, image_filename, image_width, image_height, created_at, updated_at)
679
+ VALUES (${screenId}, ${projectId}, ${screen.name}, ${screen.imageUrl ?? ''}, ${screen.imageFilename ?? null},
680
+ ${screen.imageWidth ?? 0}, ${screen.imageHeight ?? 0}, NOW(), NOW())
614
681
  `;
682
+ if (screen.regions) {
683
+ for (const region of screen.regions) {
684
+ const regionId = region.id ?? randomUUID();
685
+ await sql `
686
+ INSERT INTO screen_regions (id, screen_id, project_id, label, position_x, position_y, size_width, size_height, component_node_id, created_at, updated_at)
687
+ VALUES (${regionId}, ${screenId}, ${projectId}, ${region.label ?? null},
688
+ ${region.position?.x ?? 0}, ${region.position?.y ?? 0},
689
+ ${region.size?.width ?? 0}, ${region.size?.height ?? 0},
690
+ ${region.componentNodeId ?? null}, NOW(), NOW())
691
+ `;
692
+ if (region.elementIds) {
693
+ for (let i = 0; i < region.elementIds.length; i++) {
694
+ await sql `
695
+ INSERT INTO region_elements (region_id, node_id, position_order, created_at)
696
+ VALUES (${regionId}, ${region.elementIds[i]}, ${i}, NOW())
697
+ `;
698
+ }
699
+ }
700
+ }
701
+ }
615
702
  }
616
703
  }
617
- return { id: subviewId };
618
- }
619
- export async function deleteSubviewViaApi(projectId, subviewId) {
620
- if (MODE === 'local') {
621
- const res = await fetchLocal(`/api/projects/${projectId}/subviews/${subviewId}`, { method: 'DELETE' });
622
- return res.ok;
623
- }
624
- // Cloud mode: direct SQL delete (cascades to subview_nodes)
625
- const rows = await sql `
626
- DELETE FROM subviews
627
- WHERE id = ${subviewId}
628
- RETURNING id
629
- `;
630
- return rows.length > 0;
704
+ return {
705
+ nodeCount: canvasState.nodes.length,
706
+ edgeCount: canvasState.edges.length,
707
+ screenCount: canvasState.screens?.length ?? 0,
708
+ };
631
709
  }
632
710
  //# sourceMappingURL=db.js.map