opencons 0.1.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +382 -0
  3. package/opencons.d.ts +55 -0
  4. package/package.json +73 -0
  5. package/scripts/vendor-d3.js +22 -0
  6. package/src/core/context.js +44 -0
  7. package/src/core/index.js +198 -0
  8. package/src/core/tracer.js +252 -0
  9. package/src/drivers/db-language.js +207 -0
  10. package/src/drivers/detect.js +62 -0
  11. package/src/drivers/drizzle.js +87 -0
  12. package/src/drivers/index.js +43 -0
  13. package/src/drivers/mongoose.js +89 -0
  14. package/src/drivers/mysql2.js +116 -0
  15. package/src/drivers/pg.js +130 -0
  16. package/src/drivers/prisma.js +109 -0
  17. package/src/drivers/record.js +158 -0
  18. package/src/index.js +28 -0
  19. package/src/integrations/nest-lifecycle.js +357 -0
  20. package/src/integrations/nest.js +89 -0
  21. package/src/interceptors/express.js +270 -0
  22. package/src/interceptors/require-hook.js +109 -0
  23. package/src/lib/config.js +139 -0
  24. package/src/lib/errors.js +54 -0
  25. package/src/lib/http-response.js +37 -0
  26. package/src/lib/logger.js +69 -0
  27. package/src/lib/serialize.js +22 -0
  28. package/src/server/static.js +165 -0
  29. package/src/server/ws.js +62 -0
  30. package/src/store/source-cache.js +120 -0
  31. package/src/store/trace-store.js +117 -0
  32. package/src/transform/ast.js +255 -0
  33. package/src/transform/natural-language.js +146 -0
  34. package/src/transform/probe.js +161 -0
  35. package/src/transform/register.js +44 -0
  36. package/src/utils/label.js +26 -0
  37. package/src/utils/observable.js +103 -0
  38. package/widget/app.js +356 -0
  39. package/widget/db-language.js +90 -0
  40. package/widget/graph.js +1167 -0
  41. package/widget/index.html +132 -0
  42. package/widget/styles.css +773 -0
  43. package/widget/timeline.js +57 -0
  44. package/widget/vendor/d3.min.js +2 -0
@@ -0,0 +1,1167 @@
1
+ 'use strict';
2
+
3
+ const NODE_COLORS = {
4
+ request: '#6b7280',
5
+ response: '#6b7280',
6
+ middleware: '#ff7a45',
7
+ controller: '#a78bfa',
8
+ branch: '#f59e0b',
9
+ loop: '#ff4d00',
10
+ db: '#3b82f6',
11
+ error: '#ef4444',
12
+ ghost: '#4b5563',
13
+ };
14
+
15
+ const PATH_PALETTE = ['#ff4d00', '#a78bfa', '#60a5fa', '#f472b6', '#fb923c', '#34d399', '#fbbf24'];
16
+ const SURFACE_FILL = '#222222';
17
+ const SURFACE_STROKE = '#3a3a3a';
18
+
19
+ const NODE_W = 148;
20
+ const NODE_H = 42;
21
+ const DB_HUB_W = 228;
22
+ const DB_HUB_H = 84;
23
+ const DB_QUERY_W = 176;
24
+ const DB_QUERY_H = 52;
25
+ const DB_QUERY_GAP = 64;
26
+ const DB_LANE_OFFSET = 58;
27
+ const DB_HUB_GAP = 96;
28
+ const DECISION_W = 260;
29
+ const DECISION_H = 118;
30
+ const GHOST_W = 148;
31
+ const GHOST_H = 34;
32
+ const GAP_STD = 110;
33
+ const GAP_DECISION = 180;
34
+ const BRANCH_OFFSET = 92;
35
+ const MIN_EDGE_GAP = 48;
36
+ const OUTCOME_H = 19;
37
+ const CANVAS_PAD = 96;
38
+
39
+ /** @type {d3.ZoomBehavior | null} */
40
+ let activeZoom = null;
41
+
42
+ /** @type {d3.Selection | null} */
43
+ let activeSvg = null;
44
+
45
+ /** @type {d3.Selection | null} */
46
+ let activeRoot = null;
47
+
48
+ window.OpenconsGraph = {
49
+ /**
50
+ * @param {object} trace
51
+ * @param {(node: object) => void} onNodeSelect
52
+ */
53
+ render(trace, onNodeSelect) {
54
+ const svg = d3.select('#graph-svg');
55
+ svg.selectAll('*').remove();
56
+
57
+ const container = document.getElementById('graph-view');
58
+ const width = container.clientWidth;
59
+ const height = container.clientHeight;
60
+
61
+ const displayNodes = prepareDisplayNodes(trace.nodes);
62
+ const spine = buildExecutionPath(displayNodes, trace.edges);
63
+ const layout = layoutGraph(displayNodes, trace.edges, spine, width, height);
64
+
65
+ svg.attr('viewBox', [0, 0, layout.width, layout.height]);
66
+
67
+ const tooltip = document.getElementById('node-tooltip');
68
+ const g = svg.append('g').attr('class', 'graph-root');
69
+
70
+ const zoom = d3
71
+ .zoom()
72
+ .scaleExtent([0.02, 16])
73
+ .filter((event) => {
74
+ if (event.type === 'wheel') return true;
75
+ if (event.type.startsWith('touch')) return !event.target.closest?.('.graph-node');
76
+ if (event.button && event.button !== 0) return false;
77
+ if (event.type === 'mousedown') return !event.target.closest?.('.graph-node');
78
+ return true;
79
+ })
80
+ .on('zoom', (event) => g.attr('transform', event.transform));
81
+
82
+ svg.call(zoom);
83
+ activeZoom = zoom;
84
+ activeSvg = svg;
85
+ activeRoot = g;
86
+
87
+ const defs = svg.append('defs');
88
+
89
+ const dotPattern = defs
90
+ .append('pattern')
91
+ .attr('id', 'dot-grid')
92
+ .attr('width', 22)
93
+ .attr('height', 22)
94
+ .attr('patternUnits', 'userSpaceOnUse');
95
+ dotPattern.append('circle').attr('cx', 1).attr('cy', 1).attr('r', 1).attr('fill', '#2a3042');
96
+
97
+ const shadow = defs.append('filter').attr('id', 'node-shadow').attr('x', '-20%').attr('y', '-20%').attr('width', '140%').attr('height', '140%');
98
+ shadow.append('feDropShadow').attr('dx', 0).attr('dy', 2).attr('stdDeviation', 3).attr('flood-color', '#000').attr('flood-opacity', 0.35);
99
+
100
+ g.insert('rect', ':first-child')
101
+ .attr('class', 'graph-bg')
102
+ .attr('x', 0)
103
+ .attr('y', 0)
104
+ .attr('width', layout.width)
105
+ .attr('height', layout.height)
106
+ .attr('fill', 'url(#dot-grid)');
107
+
108
+ g.selectAll('.graph-link')
109
+ .data(layout.links)
110
+ .join('path')
111
+ .attr('class', (d) =>
112
+ ['graph-link', d.active ? 'active' : 'inactive', d.kind || ''].filter(Boolean).join(' ')
113
+ )
114
+ .attr('stroke', (d) => linkColor(d))
115
+ .attr('d', (d) => mindMapLink(d.source, d.target, d.kind));
116
+
117
+ const drag = d3
118
+ .drag()
119
+ .clickDistance(5)
120
+ .on('start', function onDragStart(event) {
121
+ event.sourceEvent.stopPropagation();
122
+ d3.select(this).raise().classed('is-dragging', true);
123
+ })
124
+ .on('drag', function onDrag(event, d) {
125
+ const transform = d3.zoomTransform(svg.node());
126
+ d.x += event.dx / transform.k;
127
+ d.y += event.dy / transform.k;
128
+ d3.select(this).attr('transform', `translate(${d.x},${d.y})`);
129
+ refreshGraphLinks(g);
130
+ })
131
+ .on('end', function onDragEnd(event, d) {
132
+ d3.select(this).classed('is-dragging', false);
133
+ if (event.sourceEvent.defaultPrevented) return;
134
+ const dx = event.x - event.subject.x;
135
+ const dy = event.y - event.subject.y;
136
+ if (Math.hypot(dx, dy) < 4 && !d.isGhost) {
137
+ onNodeSelect(d);
138
+ }
139
+ });
140
+
141
+ const nodeGroups = g
142
+ .selectAll('.graph-node')
143
+ .data(layout.nodes)
144
+ .join('g')
145
+ .attr('class', (d) =>
146
+ [
147
+ 'graph-node',
148
+ `type-${d.type}`,
149
+ d.isDecision ? 'is-decision' : '',
150
+ d.isGhost ? 'is-ghost' : '',
151
+ d.onSpine === false ? 'is-stub' : '',
152
+ d.isDbHub ? 'is-db-hub' : '',
153
+ d.isDbQuery ? 'is-db-query' : '',
154
+ ]
155
+ .filter(Boolean)
156
+ .join(' ')
157
+ )
158
+ .attr('transform', (d) => `translate(${d.x},${d.y})`)
159
+ .call(drag)
160
+ .on('mouseenter', (event, d) => showTooltip(event, d, tooltip))
161
+ .on('mousemove', (event) => moveTooltip(event, tooltip))
162
+ .on('mouseleave', () => hideTooltip(tooltip));
163
+
164
+ nodeGroups.each(function drawNode(d) {
165
+ const group = d3.select(this);
166
+
167
+ if (d.isGhost) {
168
+ renderGhostNode(group, d);
169
+ } else if (d.isDecision) {
170
+ renderDecisionNode(group, d);
171
+ } else if (d.isDbHub) {
172
+ renderDbHub(group, d);
173
+ } else if (d.type === 'db' || d.isDbQuery) {
174
+ renderDbQueryNode(group, d);
175
+ } else {
176
+ renderStandardNode(group, d);
177
+ }
178
+ });
179
+
180
+ requestAnimationFrame(() => {
181
+ fitGraphToView(svg, zoom, g, width, height);
182
+ });
183
+ },
184
+
185
+ zoomIn() {
186
+ if (!activeSvg || !activeZoom) return;
187
+ activeSvg.transition().duration(180).call(activeZoom.scaleBy, 1.35);
188
+ },
189
+
190
+ zoomOut() {
191
+ if (!activeSvg || !activeZoom) return;
192
+ activeSvg.transition().duration(180).call(activeZoom.scaleBy, 0.74);
193
+ },
194
+
195
+ resetView() {
196
+ if (!activeSvg || !activeZoom || !activeRoot) return;
197
+ const container = document.getElementById('graph-view');
198
+ fitGraphToView(activeSvg, activeZoom, activeRoot, container.clientWidth, container.clientHeight);
199
+ },
200
+ };
201
+
202
+ /**
203
+ * @param {object[]} nodes
204
+ */
205
+ function prepareDisplayNodes(nodes) {
206
+ return nodes
207
+ .filter((node) => {
208
+ if (node.source?.kind !== 'else') return true;
209
+ return !node.outcomes;
210
+ })
211
+ .map((node) => finalizeBranchOutcomes(node));
212
+ }
213
+
214
+ /**
215
+ * @param {object} node
216
+ */
217
+ function finalizeBranchOutcomes(node) {
218
+ if (!node.outcomes?.length) return node;
219
+
220
+ if (node.value === false && node.has_else && node.taken_outcome !== 'else') {
221
+ return {
222
+ ...node,
223
+ outcomes: node.outcomes.map((outcome) =>
224
+ outcome.key === 'else'
225
+ ? { ...outcome, label: 'Else block — skipped', taken: false }
226
+ : outcome
227
+ ),
228
+ };
229
+ }
230
+
231
+ return node;
232
+ }
233
+
234
+ /**
235
+ * @param {object[]} nodes
236
+ * @param {object[]} edges
237
+ */
238
+ function buildExecutionPath(nodes, edges) {
239
+ const byId = new Map(nodes.map((n) => [n.id, { ...n }]));
240
+ const adjacency = new Map();
241
+
242
+ for (const edge of edges) {
243
+ if (edge.parallel) continue;
244
+ if (!adjacency.has(edge.from)) adjacency.set(edge.from, []);
245
+ adjacency.get(edge.from).push(edge.to);
246
+ }
247
+
248
+ const start =
249
+ nodes.find((n) => n.type === 'request') ||
250
+ nodes.find((n) => !edges.some((e) => e.to === n.id));
251
+
252
+ if (!start) return nodes.map((n) => ({ ...n }));
253
+
254
+ const path = [];
255
+ const visited = new Set();
256
+ let current = start.id;
257
+
258
+ while (current && !visited.has(current)) {
259
+ visited.add(current);
260
+ const node = byId.get(current);
261
+ if (node) path.push(node);
262
+ const nextIds = adjacency.get(current) || [];
263
+ current = nextIds[0];
264
+ }
265
+
266
+ return path;
267
+ }
268
+
269
+ /**
270
+ * @param {object[]} nodes
271
+ * @param {object[]} edges
272
+ * @param {object[]} spine
273
+ * @param {number} viewportWidth
274
+ * @param {number} viewportHeight
275
+ */
276
+ function layoutGraph(nodes, edges, spine, viewportWidth, viewportHeight) {
277
+ const base = layoutHorizontalTree(spine, viewportWidth, viewportHeight);
278
+ layoutDbHub(nodes, edges, base);
279
+
280
+ const maxY = Math.max(...base.nodes.map((node) => node.y + node.nodeHeight / 2), 0);
281
+ const minY = Math.min(...base.nodes.map((node) => node.y - node.nodeHeight / 2), 0);
282
+ base.height = Math.max(base.height, maxY - minY + CANVAS_PAD * 2);
283
+
284
+ return base;
285
+ }
286
+
287
+ /**
288
+ * Route every DB query through one global Database hub:
289
+ * handler → query card → hub → query card → handler
290
+ * @param {object[]} nodes
291
+ * @param {object[]} edges
292
+ * @param {{ nodes: object[], links: object[], height: number }} base
293
+ */
294
+ function layoutDbHub(nodes, edges, base) {
295
+ const dbNodes = nodes.filter((node) => node.type === 'db');
296
+ if (!dbNodes.length) return;
297
+
298
+ const byId = new Map(base.nodes.map((node) => [node.id, node]));
299
+ const parallelEdges = edges.filter((edge) => edge.parallel);
300
+ const spineNodes = base.nodes.filter((node) => node.onSpine);
301
+ const minX = Math.min(...spineNodes.map((node) => node.x), CANVAS_PAD);
302
+ const maxX = Math.max(...spineNodes.map((node) => node.x), CANVAS_PAD + 200);
303
+ const spineY = spineNodes[0]?.y || base.height / 2;
304
+ const hubX = (minX + maxX) / 2;
305
+
306
+ const drivers = [...new Set(dbNodes.map((node) => node.driver).filter(Boolean))];
307
+ const parentQueryCount = new Map();
308
+ const layoutQueries = [];
309
+
310
+ for (const dbNode of dbNodes) {
311
+ const edge = parallelEdges.find((entry) => entry.to === dbNode.id);
312
+ const parent = edge?.from ? byId.get(edge.from) : null;
313
+ if (!parent) continue;
314
+
315
+ const laneIndex = parentQueryCount.get(parent.id) || 0;
316
+ parentQueryCount.set(parent.id, laneIndex + 1);
317
+
318
+ layoutQueries.push({
319
+ dbNode,
320
+ parent,
321
+ laneIndex,
322
+ });
323
+ }
324
+
325
+ if (!layoutQueries.length) return;
326
+
327
+ let maxQueryBottom = spineY;
328
+
329
+ for (const entry of layoutQueries) {
330
+ const { dbNode, parent, laneIndex } = entry;
331
+ const queryY = parent.y + parent.nodeHeight / 2 + DB_LANE_OFFSET + laneIndex * DB_QUERY_GAP;
332
+ maxQueryBottom = Math.max(maxQueryBottom, queryY + DB_QUERY_H / 2);
333
+
334
+ const layoutQuery = {
335
+ ...dbNode,
336
+ x: parent.x,
337
+ y: queryY,
338
+ lane: laneIndex,
339
+ depth: parent.depth + 0.2,
340
+ onSpine: false,
341
+ isDbQuery: true,
342
+ isDbHub: false,
343
+ isDecision: false,
344
+ isGhost: false,
345
+ parentNodeId: parent.id,
346
+ parentLabel: parent.label,
347
+ nodeWidth: DB_QUERY_W,
348
+ nodeHeight: DB_QUERY_H,
349
+ pathColor: '#3b82f6',
350
+ };
351
+
352
+ base.nodes.push(layoutQuery);
353
+ entry.layoutQuery = layoutQuery;
354
+ }
355
+
356
+ const hubY = maxQueryBottom + DB_HUB_GAP;
357
+ const hubNode = {
358
+ id: '__db_hub__',
359
+ type: 'db-hub',
360
+ label: 'Database',
361
+ isDbHub: true,
362
+ isDbQuery: false,
363
+ queryCount: layoutQueries.length,
364
+ drivers,
365
+ dbQueries: layoutQueries.map((entry) => entry.dbNode),
366
+ x: hubX,
367
+ y: hubY,
368
+ lane: 0,
369
+ depth: 999,
370
+ onSpine: false,
371
+ isDecision: false,
372
+ isGhost: false,
373
+ nodeWidth: DB_HUB_W,
374
+ nodeHeight: DB_HUB_H,
375
+ pathColor: '#2563eb',
376
+ };
377
+
378
+ base.nodes.push(hubNode);
379
+
380
+ for (const entry of layoutQueries) {
381
+ const { parent, layoutQuery } = entry;
382
+
383
+ base.links.push({ source: parent, target: layoutQuery, active: true, kind: 'db-out' });
384
+ base.links.push({ source: layoutQuery, target: hubNode, active: true, kind: 'db-to-hub' });
385
+ base.links.push({ source: hubNode, target: layoutQuery, active: true, kind: 'db-from-hub' });
386
+ base.links.push({ source: layoutQuery, target: parent, active: true, kind: 'db-return' });
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Horizontal decision-tree layout: flat spine left→right, branches fork locally.
392
+ * @param {object[]} spine
393
+ * @param {number} viewportWidth
394
+ * @param {number} viewportHeight
395
+ */
396
+ function layoutHorizontalTree(spine, viewportWidth, viewportHeight) {
397
+ const spineNodes = [];
398
+ const links = [];
399
+ let xCursor = CANVAS_PAD;
400
+
401
+ const graphHeight = Math.max(
402
+ viewportHeight,
403
+ BRANCH_OFFSET * 2 + DECISION_H + GHOST_H + CANVAS_PAD * 2
404
+ );
405
+ const spineY = graphHeight / 2;
406
+
407
+ spine.forEach((node, depth) => {
408
+ const isDecision = Boolean(node.outcomes?.length);
409
+ const dims = nodeDimensions(node, isDecision);
410
+ const prev = spineNodes[spineNodes.length - 1];
411
+ const gap = prev ? edgeGap(prev.isDecision, isDecision) : 0;
412
+
413
+ xCursor += gap;
414
+ const x = xCursor + dims.nodeWidth / 2;
415
+
416
+ const layoutNode = {
417
+ ...node,
418
+ x,
419
+ y: spineY,
420
+ lane: 0,
421
+ depth,
422
+ onSpine: true,
423
+ isDecision,
424
+ isGhost: false,
425
+ pathColor: PATH_PALETTE[depth % PATH_PALETTE.length],
426
+ ...dims,
427
+ };
428
+
429
+ spineNodes.push(layoutNode);
430
+ xCursor += dims.nodeWidth / 2;
431
+
432
+ if (prev) {
433
+ links.push({
434
+ source: prev,
435
+ target: layoutNode,
436
+ active: true,
437
+ kind: 'spine',
438
+ });
439
+ }
440
+ });
441
+
442
+ resolveSpineCollisions(spineNodes);
443
+
444
+ const layoutNodes = [...spineNodes];
445
+
446
+ for (const layoutNode of spineNodes) {
447
+ if (!layoutNode.isDecision) continue;
448
+
449
+ for (const outcome of layoutNode.outcomes) {
450
+ if (outcome.taken) continue;
451
+
452
+ const above = outcome.key === 'then';
453
+ const branchY = above
454
+ ? spineY - layoutNode.nodeHeight / 2 - BRANCH_OFFSET - GHOST_H / 2
455
+ : spineY + layoutNode.nodeHeight / 2 + BRANCH_OFFSET + GHOST_H / 2;
456
+
457
+ const ghost = {
458
+ id: `${layoutNode.id}__ghost__${outcome.key}`,
459
+ type: 'ghost',
460
+ label: outcome.label,
461
+ summary: 'Not taken',
462
+ x: layoutNode.x,
463
+ y: branchY,
464
+ lane: 0,
465
+ depth: layoutNode.depth + 0.35,
466
+ onSpine: false,
467
+ isGhost: true,
468
+ isDecision: false,
469
+ nodeWidth: GHOST_W,
470
+ nodeHeight: GHOST_H,
471
+ outcomeKey: outcome.key,
472
+ branchAbove: above,
473
+ pathColor: layoutNode.pathColor,
474
+ };
475
+
476
+ layoutNodes.push(ghost);
477
+ links.push({
478
+ source: layoutNode,
479
+ target: ghost,
480
+ active: false,
481
+ kind: 'branch',
482
+ branchKey: outcome.key,
483
+ branchAbove: above,
484
+ });
485
+ }
486
+ }
487
+
488
+ const last = spineNodes[spineNodes.length - 1];
489
+ const graphWidth = last
490
+ ? Math.max(viewportWidth, last.x + last.nodeWidth / 2 + CANVAS_PAD)
491
+ : viewportWidth;
492
+
493
+ return {
494
+ nodes: layoutNodes,
495
+ links,
496
+ width: graphWidth,
497
+ height: graphHeight,
498
+ };
499
+ }
500
+
501
+ /**
502
+ * Empty space between the right edge of one node and the left edge of the next.
503
+ * @param {boolean} prevIsDecision
504
+ * @param {boolean} nextIsDecision
505
+ */
506
+ function edgeGap(prevIsDecision, nextIsDecision) {
507
+ const preferred = prevIsDecision || nextIsDecision ? GAP_DECISION : GAP_STD;
508
+ return preferred + MIN_EDGE_GAP;
509
+ }
510
+
511
+ /**
512
+ * Push spine nodes apart so bounding boxes never overlap.
513
+ * @param {object[]} spineNodes
514
+ */
515
+ function resolveSpineCollisions(spineNodes) {
516
+ for (let i = 1; i < spineNodes.length; i += 1) {
517
+ const prev = spineNodes[i - 1];
518
+ const curr = spineNodes[i];
519
+ const requiredGap = edgeGap(prev.isDecision, curr.isDecision);
520
+ const actualGap = curr.x - prev.x - prev.nodeWidth / 2 - curr.nodeWidth / 2;
521
+ const shift = requiredGap - actualGap;
522
+
523
+ if (shift > 0) {
524
+ for (let j = i; j < spineNodes.length; j += 1) {
525
+ spineNodes[j].x += shift;
526
+ }
527
+ }
528
+ }
529
+ }
530
+
531
+ /**
532
+ * @param {object} node
533
+ * @param {boolean} isDecision
534
+ */
535
+ function nodeDimensions(node, isDecision) {
536
+ if (isDecision) {
537
+ return { nodeWidth: DECISION_W, nodeHeight: DECISION_H };
538
+ }
539
+
540
+ return { nodeWidth: NODE_W, nodeHeight: NODE_H };
541
+ }
542
+
543
+ /**
544
+ * @param {object} link
545
+ */
546
+ function linkColor(link) {
547
+ if (link.kind?.startsWith('db-')) return NODE_COLORS.db;
548
+ if (!link.active) return link.target.pathColor || '#4b5563';
549
+ return link.target.pathColor || link.source.pathColor || PATH_PALETTE[0];
550
+ }
551
+
552
+ /**
553
+ * Smooth mind-map style connectors.
554
+ * @param {object} source
555
+ * @param {object} target
556
+ * @param {string} [kind]
557
+ */
558
+ function mindMapLink(source, target, kind) {
559
+ const sw = source.nodeWidth / 2;
560
+ const tw = target.nodeWidth / 2;
561
+
562
+ if (kind === 'branch') {
563
+ const above = target.y < source.y;
564
+ const startY = above ? source.y - source.nodeHeight / 2 : source.y + source.nodeHeight / 2;
565
+ const endY = above ? target.y + target.nodeHeight / 2 : target.y - target.nodeHeight / 2;
566
+ const bulge = 56;
567
+ return `M${source.x},${startY} C${source.x + bulge},${startY} ${target.x + bulge},${endY} ${target.x},${endY}`;
568
+ }
569
+
570
+ if (kind === 'db-out') {
571
+ const x1 = source.x;
572
+ const y1 = source.y + source.nodeHeight / 2;
573
+ const x2 = target.x;
574
+ const y2 = target.y - target.nodeHeight / 2;
575
+ const midY = (y1 + y2) / 2;
576
+ return `M${x1},${y1} C${x1},${midY} ${x2},${midY} ${x2},${y2}`;
577
+ }
578
+
579
+ if (kind === 'db-to-hub') {
580
+ const x1 = source.x + 10;
581
+ const y1 = source.y + source.nodeHeight / 2;
582
+ const x2 = target.x + 14;
583
+ const y2 = target.y - target.nodeHeight / 2;
584
+ const bend = Math.abs(x2 - x1) * 0.35 + 36;
585
+ return `M${x1},${y1} C${x1},${y1 + bend} ${x2},${y2 - bend} ${x2},${y2}`;
586
+ }
587
+
588
+ if (kind === 'db-from-hub') {
589
+ const x1 = source.x - 10;
590
+ const y1 = source.y - source.nodeHeight / 2;
591
+ const x2 = target.x - 14;
592
+ const y2 = target.y + target.nodeHeight / 2;
593
+ const bend = Math.abs(x2 - x1) * 0.25 + 28;
594
+ return `M${x1},${y1} C${x1},${y1 - bend} ${x2},${y2 + bend} ${x2},${y2}`;
595
+ }
596
+
597
+ if (kind === 'db-return') {
598
+ const x1 = source.x;
599
+ const y1 = source.y - source.nodeHeight / 2;
600
+ const x2 = target.x;
601
+ const y2 = target.y - target.nodeHeight / 2;
602
+ const lift = Math.max(42, Math.abs(y2 - y1) * 0.45);
603
+ return `M${x1},${y1} C${x1 - 28},${y1 - lift} ${x2 + 28},${y2 - lift} ${x2},${y2}`;
604
+ }
605
+
606
+ const x1 = source.x + sw;
607
+ const x2 = target.x - tw;
608
+ const y1 = source.y;
609
+ const y2 = target.y;
610
+ const curve = Math.max(50, (x2 - x1) * 0.5);
611
+
612
+ return `M${x1},${y1} C${x1 + curve},${y1} ${x2 - curve},${y2} ${x2},${y2}`;
613
+ }
614
+
615
+ function renderStandardNode(group, d) {
616
+ const halfW = NODE_W / 2;
617
+ const halfH = NODE_H / 2;
618
+ const accent = d.pathColor || NODE_COLORS[d.type] || NODE_COLORS.middleware;
619
+ const isRoot = d.type === 'request';
620
+ const w = isRoot ? NODE_W + 12 : NODE_W;
621
+ const h = isRoot ? NODE_H + 6 : NODE_H;
622
+ const hw = w / 2;
623
+ const hh = h / 2;
624
+
625
+ group.attr('filter', 'url(#node-shadow)');
626
+
627
+ group
628
+ .append('rect')
629
+ .attr('x', -hw)
630
+ .attr('y', -hh)
631
+ .attr('width', w)
632
+ .attr('height', h)
633
+ .attr('rx', hh)
634
+ .attr('fill', SURFACE_FILL)
635
+ .attr('stroke', isRoot ? accent : SURFACE_STROKE)
636
+ .attr('stroke-width', isRoot ? 2.5 : 1.5);
637
+
638
+ group
639
+ .append('rect')
640
+ .attr('x', -hw)
641
+ .attr('y', -hh)
642
+ .attr('width', 5)
643
+ .attr('height', h)
644
+ .attr('rx', 2)
645
+ .attr('fill', accent);
646
+
647
+ group
648
+ .append('text')
649
+ .attr('text-anchor', 'middle')
650
+ .attr('dy', '0.35em')
651
+ .attr('fill', '#e8eaf0')
652
+ .attr('font-size', isRoot ? 12 : 11)
653
+ .attr('font-weight', 600)
654
+ .text(truncate(shortNodeTitle(d), 20));
655
+
656
+ const caption = typeCaption(d);
657
+ if (caption) {
658
+ group
659
+ .append('text')
660
+ .attr('text-anchor', 'middle')
661
+ .attr('dy', hh + 14)
662
+ .attr('fill', '#8b90a5')
663
+ .attr('font-size', 9.5)
664
+ .text(caption);
665
+ }
666
+ }
667
+
668
+ /**
669
+ * @param {d3.Selection} group
670
+ * @param {object} d
671
+ */
672
+ function renderDecisionNode(group, d) {
673
+ const halfW = DECISION_W / 2;
674
+ const halfH = DECISION_H / 2;
675
+ const accent = d.pathColor || NODE_COLORS.branch;
676
+
677
+ group.attr('filter', 'url(#node-shadow)');
678
+
679
+ group
680
+ .append('rect')
681
+ .attr('class', 'decision-card')
682
+ .attr('x', -halfW)
683
+ .attr('y', -halfH)
684
+ .attr('width', DECISION_W)
685
+ .attr('height', DECISION_H)
686
+ .attr('rx', 14)
687
+ .attr('fill', SURFACE_FILL)
688
+ .attr('stroke', accent)
689
+ .attr('stroke-width', 2.5);
690
+
691
+ group
692
+ .append('text')
693
+ .attr('class', 'decision-eyebrow')
694
+ .attr('text-anchor', 'middle')
695
+ .attr('x', 0)
696
+ .attr('y', -halfH + 16)
697
+ .attr('fill', accent)
698
+ .attr('font-size', 9)
699
+ .attr('font-weight', 700)
700
+ .attr('letter-spacing', '0.06em')
701
+ .text('DECISION');
702
+
703
+ group
704
+ .append('text')
705
+ .attr('class', 'decision-title')
706
+ .attr('text-anchor', 'middle')
707
+ .attr('x', 0)
708
+ .attr('y', -halfH + 34)
709
+ .attr('fill', '#f3f4f6')
710
+ .attr('font-size', 11)
711
+ .attr('font-weight', 600)
712
+ .call(wrapText, DECISION_W - 22, 2);
713
+
714
+ group.select('.decision-title').text(shortNodeTitle(d));
715
+
716
+ const summary = d.summary || '';
717
+ if (summary) {
718
+ group
719
+ .append('text')
720
+ .attr('text-anchor', 'middle')
721
+ .attr('x', 0)
722
+ .attr('y', -halfH + 56)
723
+ .attr('fill', '#9ca3af')
724
+ .attr('font-size', 10)
725
+ .text(truncate(summary, 38));
726
+ }
727
+
728
+ const outcomes = d.outcomes || [];
729
+ const startY = -halfH + (summary ? 72 : 64);
730
+
731
+ outcomes.forEach((outcome, index) => {
732
+ const y = startY + index * OUTCOME_H;
733
+ const taken = Boolean(outcome.taken);
734
+ const chipColor = taken ? '#22c55e' : '#4b5563';
735
+
736
+ group
737
+ .append('rect')
738
+ .attr('class', `outcome-pill${taken ? ' taken' : ''}`)
739
+ .attr('x', -halfW + 10)
740
+ .attr('y', y - 12)
741
+ .attr('width', DECISION_W - 20)
742
+ .attr('height', 16)
743
+ .attr('rx', 8)
744
+ .attr('fill', taken ? 'rgba(34, 197, 94, 0.15)' : 'rgba(75, 85, 99, 0.12)')
745
+ .attr('stroke', chipColor)
746
+ .attr('stroke-width', 1)
747
+ .attr('opacity', taken ? 1 : 0.65);
748
+
749
+ group
750
+ .append('text')
751
+ .attr('x', -halfW + 16)
752
+ .attr('y', y)
753
+ .attr('fill', taken ? '#bbf7d0' : '#9ca3af')
754
+ .attr('font-size', 9.5)
755
+ .attr('font-weight', taken ? 700 : 500)
756
+ .text(`${taken ? '✓' : '○'} ${truncate(outcome.label, 32)}`);
757
+ });
758
+ }
759
+
760
+ /**
761
+ * @param {d3.Selection} group
762
+ * @param {object} d
763
+ */
764
+ function renderDbHub(group, d) {
765
+ const halfW = DB_HUB_W / 2;
766
+ const halfH = DB_HUB_H / 2;
767
+ const driverLabel = describeDbDrivers(d.drivers);
768
+ const queryLabel = d.queryCount === 1 ? '1 query' : `${d.queryCount} queries`;
769
+
770
+ group.attr('filter', 'url(#node-shadow)');
771
+
772
+ group
773
+ .append('rect')
774
+ .attr('x', -halfW)
775
+ .attr('y', -halfH)
776
+ .attr('width', DB_HUB_W)
777
+ .attr('height', DB_HUB_H)
778
+ .attr('rx', 16)
779
+ .attr('fill', 'rgba(37, 99, 235, 0.18)')
780
+ .attr('stroke', '#2563eb')
781
+ .attr('stroke-width', 2.5);
782
+
783
+ group
784
+ .append('rect')
785
+ .attr('x', -halfW)
786
+ .attr('y', -halfH)
787
+ .attr('width', DB_HUB_W)
788
+ .attr('height', 8)
789
+ .attr('rx', 16)
790
+ .attr('fill', '#3b82f6');
791
+
792
+ group
793
+ .append('ellipse')
794
+ .attr('cx', -halfW + 28)
795
+ .attr('cy', -6)
796
+ .attr('rx', 16)
797
+ .attr('ry', 5)
798
+ .attr('fill', 'rgba(147, 197, 253, 0.35)')
799
+ .attr('stroke', '#93c5fd')
800
+ .attr('stroke-width', 1.2);
801
+
802
+ group
803
+ .append('rect')
804
+ .attr('x', -halfW + 12)
805
+ .attr('y', -2)
806
+ .attr('width', 32)
807
+ .attr('height', 28)
808
+ .attr('rx', 4)
809
+ .attr('fill', 'rgba(30, 58, 138, 0.55)')
810
+ .attr('stroke', '#60a5fa')
811
+ .attr('stroke-width', 1.5);
812
+
813
+ group
814
+ .append('text')
815
+ .attr('x', -halfW + 56)
816
+ .attr('y', -14)
817
+ .attr('fill', '#eff6ff')
818
+ .attr('font-size', 13)
819
+ .attr('font-weight', 700)
820
+ .text('Database');
821
+
822
+ group
823
+ .append('text')
824
+ .attr('x', -halfW + 56)
825
+ .attr('y', 2)
826
+ .attr('fill', '#bfdbfe')
827
+ .attr('font-size', 9.5)
828
+ .text(truncate(driverLabel, 24));
829
+
830
+ group
831
+ .append('text')
832
+ .attr('x', -halfW + 56)
833
+ .attr('y', 18)
834
+ .attr('fill', '#93c5fd')
835
+ .attr('font-size', 9)
836
+ .text(queryLabel);
837
+
838
+ group
839
+ .append('text')
840
+ .attr('text-anchor', 'middle')
841
+ .attr('y', halfH - 12)
842
+ .attr('fill', '#7dd3fc')
843
+ .attr('font-size', 8.5)
844
+ .attr('font-weight', 600)
845
+ .text('All queries route here');
846
+ }
847
+
848
+ /**
849
+ * @param {string[]} [drivers]
850
+ */
851
+ function describeDbDrivers(drivers) {
852
+ if (!drivers?.length) return 'SQL datastore';
853
+
854
+ const labels = drivers.map((driver) => {
855
+ if (driver === 'drizzle') return 'Drizzle';
856
+ if (driver === 'pg') return 'PostgreSQL';
857
+ if (driver === 'mysql2') return 'MySQL';
858
+ if (driver === 'prisma') return 'Prisma';
859
+ if (driver === 'mongoose') return 'MongoDB';
860
+ return driver;
861
+ });
862
+
863
+ return labels.join(' · ');
864
+ }
865
+
866
+ function renderDbQueryNode(group, d) {
867
+ const halfW = DB_QUERY_W / 2;
868
+ const halfH = DB_QUERY_H / 2;
869
+ const lang = window.OpenconsDbLanguage;
870
+ const icon = lang ? lang.dbActionIcon(d) : '◎';
871
+ const title = lang ? lang.dbNodeTitle(d) : d.label || 'Query';
872
+ const intent = lang ? lang.dbNodeIntent(d) : 'Database query';
873
+ const result = lang ? lang.dbNodeResult(d) : d.summary || '';
874
+ const failed = Boolean(d.exit_reason);
875
+
876
+ group.attr('filter', 'url(#node-shadow)');
877
+
878
+ group
879
+ .append('rect')
880
+ .attr('x', -halfW)
881
+ .attr('y', -halfH)
882
+ .attr('width', DB_QUERY_W)
883
+ .attr('height', DB_QUERY_H)
884
+ .attr('rx', 11)
885
+ .attr('fill', failed ? 'rgba(239, 68, 68, 0.12)' : 'rgba(59, 130, 246, 0.12)')
886
+ .attr('stroke', failed ? NODE_COLORS.error : '#60a5fa')
887
+ .attr('stroke-width', 1.8);
888
+
889
+ group
890
+ .append('text')
891
+ .attr('x', -halfW + 12)
892
+ .attr('y', -10)
893
+ .attr('fill', '#93c5fd')
894
+ .attr('font-size', 10)
895
+ .attr('font-weight', 700)
896
+ .text(icon);
897
+
898
+ group
899
+ .append('text')
900
+ .attr('x', -halfW + 26)
901
+ .attr('y', -10)
902
+ .attr('fill', '#eff6ff')
903
+ .attr('font-size', 10)
904
+ .attr('font-weight', 700)
905
+ .text(truncate(title, 18));
906
+
907
+ group
908
+ .append('text')
909
+ .attr('x', -halfW + 12)
910
+ .attr('y', 6)
911
+ .attr('fill', '#bfdbfe')
912
+ .attr('font-size', 8.8)
913
+ .text(truncate(intent, 26));
914
+
915
+ const footer = [];
916
+ if (result) footer.push(truncate(result, 22));
917
+ if (d.duration_ms != null) footer.push(`${d.duration_ms}ms`);
918
+
919
+ group
920
+ .append('text')
921
+ .attr('x', -halfW + 12)
922
+ .attr('y', 20)
923
+ .attr('fill', failed ? '#fca5a5' : '#7dd3fc')
924
+ .attr('font-size', 8.5)
925
+ .attr('font-weight', 600)
926
+ .text(footer.join(' · '));
927
+ }
928
+
929
+ function renderGhostNode(group, d) {
930
+ const halfW = GHOST_W / 2;
931
+ const halfH = GHOST_H / 2;
932
+ const accent = d.pathColor || '#4b5563';
933
+
934
+ group
935
+ .append('rect')
936
+ .attr('x', -halfW)
937
+ .attr('y', -halfH)
938
+ .attr('width', GHOST_W)
939
+ .attr('height', GHOST_H)
940
+ .attr('rx', halfH)
941
+ .attr('fill', 'rgba(30, 34, 48, 0.6)')
942
+ .attr('stroke', accent)
943
+ .attr('stroke-width', 1.5)
944
+ .attr('stroke-dasharray', '5 4')
945
+ .attr('opacity', 0.85);
946
+
947
+ group
948
+ .append('text')
949
+ .attr('text-anchor', 'middle')
950
+ .attr('dy', '0.35em')
951
+ .attr('fill', '#9ca3af')
952
+ .attr('font-size', 9.5)
953
+ .attr('font-weight', 500)
954
+ .text(truncate(d.label, 28));
955
+ }
956
+
957
+ /**
958
+ * @param {object} node
959
+ */
960
+ function shortNodeTitle(node) {
961
+ if (node.condition) {
962
+ const text = node.condition.length > 56 ? `${node.condition.slice(0, 56)}…` : node.condition;
963
+ return text;
964
+ }
965
+
966
+ return displayNodeTitle(node);
967
+ }
968
+
969
+ /**
970
+ * @param {object} node
971
+ */
972
+ function displayNodeTitle(node) {
973
+ if (node.label) return node.label;
974
+ return node.type;
975
+ }
976
+
977
+ /**
978
+ * @param {object} node
979
+ */
980
+ function typeCaption(node) {
981
+ if (node.duration_ms != null) return `${node.duration_ms}ms`;
982
+ if (node.summary) return truncate(node.summary, 22);
983
+ return node.type;
984
+ }
985
+
986
+ /**
987
+ * @param {MouseEvent} event
988
+ * @param {object} node
989
+ * @param {HTMLElement} tooltip
990
+ */
991
+ function showTooltip(event, node, tooltip) {
992
+ if (node.isGhost) {
993
+ tooltip.innerHTML = `<dt>Skipped branch</dt><dd>${escapeHtml(node.label)}</dd>`;
994
+ tooltip.classList.remove('hidden');
995
+ moveTooltip(event, tooltip);
996
+ return;
997
+ }
998
+
999
+ const lines = [`<dt>Step</dt><dd>${escapeHtml(displayNodeTitle(node))}</dd>`];
1000
+
1001
+ if (node.summary) {
1002
+ lines.push(`<dt>What happened</dt><dd>${escapeHtml(node.summary)}</dd>`);
1003
+ }
1004
+
1005
+ if (node.condition) {
1006
+ lines.push(`<dt>Condition</dt><dd>${escapeHtml(node.condition)}</dd>`);
1007
+ }
1008
+
1009
+ if (node.duration_ms != null) {
1010
+ lines.push(`<dt>Duration</dt><dd>${node.duration_ms}ms</dd>`);
1011
+ }
1012
+
1013
+ if (node.outcomes?.length) {
1014
+ const outcomeText = node.outcomes
1015
+ .map((outcome) => `${outcome.taken ? '✓' : '○'} ${outcome.label}`)
1016
+ .join('<br>');
1017
+ lines.push(`<dt>Branches</dt><dd>${outcomeText}</dd>`);
1018
+ }
1019
+
1020
+ if (node.type === 'db-hub') {
1021
+ lines.push(`<dt>Role</dt><dd>Central database — every query routes through here</dd>`);
1022
+ lines.push(`<dt>Stack</dt><dd>${escapeHtml(describeDbDrivers(node.drivers))}</dd>`);
1023
+ if (node.dbQueries?.length) {
1024
+ const queryLines = node.dbQueries
1025
+ .map((query) => {
1026
+ const lang = window.OpenconsDbLanguage;
1027
+ const title = lang ? lang.dbNodeTitle(query) : query.label;
1028
+ const result = lang ? lang.dbNodeResult(query) : query.db_result;
1029
+ return `${title} → ${result}`;
1030
+ })
1031
+ .join('<br>');
1032
+ lines.push(`<dt>Queries</dt><dd>${queryLines}</dd>`);
1033
+ }
1034
+ }
1035
+
1036
+ if (node.type === 'db' || node.isDbQuery) {
1037
+ const lang = window.OpenconsDbLanguage;
1038
+ const intent = lang ? lang.dbNodeIntent(node) : node.db_intent;
1039
+ const result = lang ? lang.dbNodeResult(node) : node.db_result;
1040
+
1041
+ if (node.parentLabel) {
1042
+ lines.push(`<dt>From</dt><dd>${escapeHtml(node.parentLabel)}</dd>`);
1043
+ }
1044
+ if (intent) lines.push(`<dt>Sent to database</dt><dd>${escapeHtml(intent)}</dd>`);
1045
+ if (result) lines.push(`<dt>Came back with</dt><dd>${escapeHtml(result)}</dd>`);
1046
+ if (node.query) lines.push(`<dt>SQL</dt><dd>${escapeHtml(node.query)}</dd>`);
1047
+ if (node.params) {
1048
+ lines.push(`<dt>Parameters</dt><dd>${escapeHtml(JSON.stringify(node.params))}</dd>`);
1049
+ }
1050
+ if (node.rows != null) lines.push(`<dt>Rows</dt><dd>${node.rows}</dd>`);
1051
+ if (node.driver) lines.push(`<dt>Driver</dt><dd>${escapeHtml(node.driver)}</dd>`);
1052
+ }
1053
+
1054
+ if (node.source?.file) {
1055
+ const loc =
1056
+ node.source.line != null ? `${node.source.file}:${node.source.line}` : node.source.file;
1057
+ lines.push(`<dt>Source</dt><dd>${escapeHtml(loc)}</dd>`);
1058
+ }
1059
+
1060
+ tooltip.innerHTML = lines.join('');
1061
+ tooltip.classList.remove('hidden');
1062
+ moveTooltip(event, tooltip);
1063
+ }
1064
+
1065
+ /**
1066
+ * @param {d3.Selection} root
1067
+ */
1068
+ function refreshGraphLinks(root) {
1069
+ root.selectAll('.graph-link').attr('d', (link) => mindMapLink(link.source, link.target, link.kind));
1070
+ }
1071
+
1072
+ /**
1073
+ * @param {d3.Selection} svg
1074
+ * @param {d3.ZoomBehavior} zoom
1075
+ * @param {d3.Selection} g
1076
+ * @param {number} width
1077
+ * @param {number} height
1078
+ */
1079
+ function fitGraphToView(svg, zoom, g, width, height) {
1080
+ const bounds = g.node()?.getBBox?.();
1081
+ if (!bounds || bounds.width === 0) return;
1082
+
1083
+ const padding = 48;
1084
+ const scaleX = (width - padding * 2) / bounds.width;
1085
+ const scaleY = (height - padding * 2) / bounds.height;
1086
+ let scale = Math.min(scaleX, scaleY);
1087
+
1088
+ // Keep text readable — don't shrink the whole graph into a blur.
1089
+ const MIN_READABLE_SCALE = 0.72;
1090
+ scale = Math.max(MIN_READABLE_SCALE, Math.min(scale, 1));
1091
+
1092
+ let tx;
1093
+ if (bounds.width * scale > width - padding * 2) {
1094
+ tx = padding - bounds.x * scale;
1095
+ } else {
1096
+ tx = (width - bounds.width * scale) / 2 - bounds.x * scale;
1097
+ }
1098
+
1099
+ const ty = (height - bounds.height * scale) / 2 - bounds.y * scale;
1100
+
1101
+ svg.call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
1102
+ }
1103
+
1104
+ /**
1105
+ * @param {d3.Selection} text
1106
+ * @param {number} width
1107
+ * @param {number} maxLines
1108
+ */
1109
+ function wrapText(text, width, maxLines) {
1110
+ text.each(function wrap() {
1111
+ const self = d3.select(this);
1112
+ const words = self.text().split(/\s+/).filter(Boolean);
1113
+ let line = [];
1114
+ let lineNumber = 0;
1115
+ let tspan = self.text(null).append('tspan').attr('x', 0).attr('dy', 0);
1116
+
1117
+ for (const word of words) {
1118
+ line.push(word);
1119
+ tspan.text(line.join(' '));
1120
+ if (tspan.node().getComputedTextLength() > width) {
1121
+ line.pop();
1122
+ tspan.text(line.join(' '));
1123
+ line = [word];
1124
+ lineNumber += 1;
1125
+ if (lineNumber >= maxLines) break;
1126
+ tspan = self.append('tspan').attr('x', 0).attr('dy', '1.1em').text(word);
1127
+ }
1128
+ }
1129
+ });
1130
+ }
1131
+
1132
+ /**
1133
+ * @param {MouseEvent} event
1134
+ * @param {HTMLElement} tooltip
1135
+ */
1136
+ function moveTooltip(event, tooltip) {
1137
+ const container = document.getElementById('graph-view');
1138
+ const rect = container.getBoundingClientRect();
1139
+
1140
+ tooltip.style.left = `${event.clientX - rect.left + 12}px`;
1141
+ tooltip.style.top = `${event.clientY - rect.top + 12}px`;
1142
+ }
1143
+
1144
+ /**
1145
+ * @param {HTMLElement} tooltip
1146
+ */
1147
+ function hideTooltip(tooltip) {
1148
+ tooltip.classList.add('hidden');
1149
+ }
1150
+
1151
+ /**
1152
+ * @param {string} str
1153
+ * @param {number} max
1154
+ */
1155
+ function truncate(str, max) {
1156
+ return str.length > max ? `${str.slice(0, max)}…` : str;
1157
+ }
1158
+
1159
+ /**
1160
+ * @param {string} str
1161
+ */
1162
+ function escapeHtml(str) {
1163
+ return str
1164
+ .replace(/&/g, '&amp;')
1165
+ .replace(/</g, '&lt;')
1166
+ .replace(/>/g, '&gt;');
1167
+ }