sqlite-hub 1.0.0 → 1.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 (40) hide show
  1. package/README.md +47 -33
  2. package/bin/sqlite-hub.js +127 -47
  3. package/frontend/js/api.js +6 -0
  4. package/frontend/js/app.js +199 -34
  5. package/frontend/js/components/modal.js +17 -1
  6. package/frontend/js/components/queryChartRenderer.js +28 -3
  7. package/frontend/js/components/queryEditor.js +20 -24
  8. package/frontend/js/components/queryHistoryDetail.js +1 -1
  9. package/frontend/js/components/queryHistoryHeader.js +48 -0
  10. package/frontend/js/components/queryHistoryList.js +132 -0
  11. package/frontend/js/components/queryHistoryPanel.js +72 -136
  12. package/frontend/js/components/structureGraph.js +699 -89
  13. package/frontend/js/components/tableDesignerEditor.js +3 -5
  14. package/frontend/js/store.js +73 -7
  15. package/frontend/js/utils/exportFilenames.js +3 -1
  16. package/frontend/js/views/charts.js +320 -169
  17. package/frontend/js/views/connections.js +59 -64
  18. package/frontend/js/views/data.js +12 -20
  19. package/frontend/js/views/editor.js +0 -2
  20. package/frontend/js/views/settings.js +78 -0
  21. package/frontend/js/views/structure.js +27 -13
  22. package/frontend/styles/components.css +155 -0
  23. package/frontend/styles/structure-graph.css +140 -35
  24. package/frontend/styles/tailwind.generated.css +1 -1
  25. package/frontend/styles/views.css +12 -6
  26. package/package.json +3 -2
  27. package/server/routes/export.js +89 -22
  28. package/server/routes/externalApi.js +84 -2
  29. package/server/routes/settings.js +37 -28
  30. package/server/services/appInfoService.js +215 -0
  31. package/server/services/databaseCommandService.js +50 -6
  32. package/server/services/sqlite/exportService.js +307 -22
  33. package/tests/api-token-auth.test.js +110 -1
  34. package/tests/cli-args.test.js +16 -3
  35. package/tests/database-command-service.test.js +39 -2
  36. package/tests/export-blob.test.js +54 -1
  37. package/tests/export-filenames.test.js +4 -0
  38. package/tests/settings-api-tokens-route.test.js +22 -1
  39. package/tests/settings-metadata.test.js +99 -1
  40. package/tests/settings-view.test.js +28 -0
@@ -7,6 +7,24 @@ let currentGraph = null;
7
7
  let mountVersion = 0;
8
8
  let persistedGraphState = null;
9
9
  const STRUCTURE_INSPECTOR_VISIBLE_STORAGE_KEY = 'sqlite_hub_structure_inspector_visible';
10
+ const STRUCTURE_GRAPH_STATE_STORAGE_PREFIX = 'sqlite_hub_structure_graph_state';
11
+ const STRUCTURE_GRAPH_STATE_STORAGE_VERSION = 1;
12
+
13
+ function getHash(input) {
14
+ let hash = 2166136261;
15
+ const text = String(input ?? '');
16
+
17
+ for (let index = 0; index < text.length; index += 1) {
18
+ hash ^= text.charCodeAt(index);
19
+ hash = Math.imul(hash, 16777619);
20
+ }
21
+
22
+ return (hash >>> 0).toString(36);
23
+ }
24
+
25
+ function getGraphStateStorageKey(schemaSignature) {
26
+ return `${STRUCTURE_GRAPH_STATE_STORAGE_PREFIX}:${getHash(schemaSignature)}`;
27
+ }
10
28
 
11
29
  function readStoredInspectorHidden() {
12
30
  try {
@@ -53,19 +71,118 @@ function getSchemaSignature(schema) {
53
71
  );
54
72
  }
55
73
 
74
+ function normalizeGraphNodePositions(nodePositions) {
75
+ if (!nodePositions || typeof nodePositions !== 'object') {
76
+ return null;
77
+ }
78
+
79
+ const normalized = {};
80
+
81
+ Object.entries(nodePositions).forEach(([nodeId, position]) => {
82
+ if (!position || typeof position !== 'object') {
83
+ return;
84
+ }
85
+
86
+ const x = Number(position.x);
87
+ const y = Number(position.y);
88
+
89
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
90
+ return;
91
+ }
92
+
93
+ normalized[nodeId] = { x, y };
94
+ });
95
+
96
+ return Object.keys(normalized).length ? normalized : null;
97
+ }
98
+
99
+ function normalizeGraphStatePayload(payload, schemaSignature) {
100
+ if (
101
+ !payload ||
102
+ typeof payload !== 'object' ||
103
+ payload.version !== STRUCTURE_GRAPH_STATE_STORAGE_VERSION ||
104
+ payload.schemaSignature !== schemaSignature
105
+ ) {
106
+ return null;
107
+ }
108
+
109
+ const nodePositions = normalizeGraphNodePositions(payload.nodePositions);
110
+
111
+ if (!nodePositions) {
112
+ return null;
113
+ }
114
+
115
+ const zoom = Number(payload.zoom);
116
+ const panX = Number(payload.pan?.x);
117
+ const panY = Number(payload.pan?.y);
118
+
119
+ return {
120
+ schemaSignature,
121
+ nodePositions,
122
+ pan: Number.isFinite(panX) && Number.isFinite(panY) ? { x: panX, y: panY } : null,
123
+ zoom: Number.isFinite(zoom) ? zoom : null,
124
+ inspectorHidden: Boolean(payload.inspectorHidden),
125
+ selectedTableName: typeof payload.selectedTableName === 'string' ? payload.selectedTableName : null,
126
+ layoutVariant: Number.isFinite(Number(payload.layoutVariant)) ? Number(payload.layoutVariant) : 0,
127
+ };
128
+ }
129
+
130
+ function readStoredGraphState(schemaSignature) {
131
+ try {
132
+ const rawValue = globalThis.localStorage?.getItem(getGraphStateStorageKey(schemaSignature));
133
+
134
+ if (!rawValue) {
135
+ return null;
136
+ }
137
+
138
+ return normalizeGraphStatePayload(JSON.parse(rawValue), schemaSignature);
139
+ } catch {
140
+ return null;
141
+ }
142
+ }
143
+
144
+ function writeStoredGraphState(graphState) {
145
+ if (!graphState?.schemaSignature || !graphState.nodePositions) {
146
+ return false;
147
+ }
148
+
149
+ try {
150
+ globalThis.localStorage?.setItem(
151
+ getGraphStateStorageKey(graphState.schemaSignature),
152
+ JSON.stringify({
153
+ version: STRUCTURE_GRAPH_STATE_STORAGE_VERSION,
154
+ schemaSignature: graphState.schemaSignature,
155
+ nodePositions: graphState.nodePositions,
156
+ pan: graphState.pan ?? null,
157
+ zoom: graphState.zoom ?? null,
158
+ inspectorHidden: Boolean(graphState.inspectorHidden),
159
+ selectedTableName: graphState.selectedTableName ?? null,
160
+ layoutVariant: Number.isFinite(Number(graphState.layoutVariant)) ? Number(graphState.layoutVariant) : 0,
161
+ savedAt: new Date().toISOString(),
162
+ }),
163
+ );
164
+ return true;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
56
170
  function getPersistedGraphState(schema) {
57
171
  const schemaSignature = getSchemaSignature(schema);
58
172
 
59
- if (persistedGraphState?.schemaSignature !== schemaSignature) {
173
+ if (persistedGraphState?.schemaSignature === schemaSignature) {
60
174
  return {
61
175
  schemaSignature,
62
- state: null,
176
+ state: persistedGraphState,
63
177
  };
64
178
  }
65
179
 
180
+ const storedState = readStoredGraphState(schemaSignature);
181
+ persistedGraphState = storedState;
182
+
66
183
  return {
67
184
  schemaSignature,
68
- state: persistedGraphState,
185
+ state: storedState,
69
186
  };
70
187
  }
71
188
 
@@ -120,6 +237,136 @@ function createColumnFlags(column, foreignKeyColumns) {
120
237
  return flags.join('');
121
238
  }
122
239
 
240
+ function quoteSqlIdentifier(identifier) {
241
+ return `"${String(identifier ?? '').replaceAll('"', '""')}"`;
242
+ }
243
+
244
+ function qualifySqlIdentifier(tableName, columnName) {
245
+ return `${quoteSqlIdentifier(tableName)}.${quoteSqlIdentifier(columnName)}`;
246
+ }
247
+
248
+ function getJoinConditions(edgeData) {
249
+ const conditions = Array.isArray(edgeData?.joinConditions) ? edgeData.joinConditions : [];
250
+
251
+ if (conditions.length) {
252
+ return conditions
253
+ .map(condition => ({
254
+ sourceTable: condition.sourceTable || edgeData.sourceTable || '',
255
+ targetTable: condition.targetTable || edgeData.targetTable || '',
256
+ sourceColumn: condition.sourceColumn || '',
257
+ targetColumn: condition.targetColumn || 'rowid',
258
+ }))
259
+ .filter(condition => condition.sourceTable && condition.targetTable && condition.sourceColumn);
260
+ }
261
+
262
+ return [
263
+ {
264
+ sourceTable: edgeData?.sourceTable || '',
265
+ targetTable: edgeData?.targetTable || '',
266
+ sourceColumn: edgeData?.sourceColumn || '',
267
+ targetColumn: edgeData?.targetColumn || 'rowid',
268
+ },
269
+ ].filter(condition => condition.sourceTable && condition.targetTable && condition.sourceColumn);
270
+ }
271
+
272
+ function buildJoinSql(edgeData) {
273
+ const sourceTable = edgeData?.sourceTable || '';
274
+ const targetTable = edgeData?.targetTable || '';
275
+ const conditions = getJoinConditions(edgeData);
276
+
277
+ if (!sourceTable || !targetTable || !conditions.length) {
278
+ return '-- Join preview is unavailable for this relationship.';
279
+ }
280
+
281
+ const joinLines = conditions.map((condition, index) => {
282
+ const prefix = index === 0 ? ' ON ' : ' AND ';
283
+
284
+ return [
285
+ prefix,
286
+ qualifySqlIdentifier(condition.sourceTable, condition.sourceColumn),
287
+ ' = ',
288
+ qualifySqlIdentifier(condition.targetTable, condition.targetColumn),
289
+ ].join('');
290
+ });
291
+
292
+ return [
293
+ 'SELECT *',
294
+ `FROM ${quoteSqlIdentifier(sourceTable)}`,
295
+ `JOIN ${quoteSqlIdentifier(targetTable)}`,
296
+ `${joinLines.join('\n')};`,
297
+ ].join('\n');
298
+ }
299
+
300
+ function renderJoinInspector(edgeData) {
301
+ const sourceTable = edgeData?.sourceTable || '';
302
+ const targetTable = edgeData?.targetTable || '';
303
+ const conditions = getJoinConditions(edgeData);
304
+ const joinSql = buildJoinSql(edgeData);
305
+
306
+ return `
307
+ <div class="structure-graph__panel">
308
+ <div class="space-y-3">
309
+ <div class="structure-graph__panel-heading">
310
+ <div class="structure-graph__eyebrow">Join Preview</div>
311
+ <button
312
+ class="query-history-icon-button"
313
+ data-structure-graph-action="toggle-inspector"
314
+ type="button"
315
+ aria-label="Hide inspector"
316
+ >
317
+ <span class="material-symbols-outlined text-[18px]">close</span>
318
+ </button>
319
+ </div>
320
+ <div class="structure-graph__title">${escapeHtml(sourceTable)} → ${escapeHtml(targetTable)}</div>
321
+ <div class="structure-graph__subtitle">Foreign key relationship</div>
322
+ </div>
323
+
324
+ <section class="structure-graph__section">
325
+ <div class="structure-graph__section-title">Relationship</div>
326
+ <div class="structure-graph__join-flow">
327
+ <div class="structure-graph__join-table">${escapeHtml(sourceTable)}</div>
328
+ <span class="material-symbols-outlined structure-graph__join-arrow" aria-hidden="true">arrow_forward</span>
329
+ <div class="structure-graph__join-table">${escapeHtml(targetTable)}</div>
330
+ </div>
331
+ <div class="structure-graph__join-condition-list">
332
+ ${
333
+ conditions.length
334
+ ? conditions
335
+ .map(
336
+ condition => `
337
+ <div class="structure-graph__join-condition">
338
+ <span>${escapeHtml(formatQualifiedEdgeColumn(condition.sourceTable, condition.sourceColumn))}</span>
339
+ <span aria-hidden="true">=</span>
340
+ <span>${escapeHtml(formatQualifiedEdgeColumn(condition.targetTable, condition.targetColumn))}</span>
341
+ </div>
342
+ `,
343
+ )
344
+ .join('')
345
+ : '<div class="text-sm text-on-surface-variant/45">No join columns found for this relationship.</div>'
346
+ }
347
+ </div>
348
+ </section>
349
+
350
+ <section class="structure-graph__section">
351
+ <div class="structure-graph__section-header">
352
+ <div class="structure-graph__section-title">SQL</div>
353
+ <button
354
+ class="standard-button"
355
+ data-structure-graph-action="copy-join-sql"
356
+ type="button"
357
+ >
358
+ <span class="material-symbols-outlined text-sm">content_copy</span>
359
+ Copy join
360
+ </button>
361
+ </div>
362
+ <pre class="structure-graph__ddl structure-graph__join-sql custom-scrollbar" data-structure-graph-join-sql>${escapeHtml(
363
+ joinSql,
364
+ )}</pre>
365
+ </section>
366
+ </div>
367
+ `;
368
+ }
369
+
123
370
  export function renderDdlSection(ddl, emptyLabel = 'No DDL available.') {
124
371
  const ddlText = typeof ddl === 'string' ? ddl : '';
125
372
  const hasDdl = Boolean(ddlText.trim());
@@ -171,11 +418,18 @@ export function renderInspector(tableData) {
171
418
  return `
172
419
  <div class="structure-graph__panel">
173
420
  <div class="space-y-3">
174
- <div class="structure-graph__eyebrow">Table Inspector</div>
175
- <div class="structure-graph__title">${escapeHtml(tableData.name)}</div>
176
- <div class="flex flex-wrap items-center gap-3">
177
- <div class="structure-graph__subtitle">${escapeHtml(tableData.type)}</div>
421
+ <div class="structure-graph__panel-heading">
422
+ <div class="structure-graph__eyebrow">Table Inspector</div>
423
+ <button
424
+ class="query-history-icon-button"
425
+ data-structure-graph-action="toggle-inspector"
426
+ type="button"
427
+ aria-label="Hide inspector"
428
+ >
429
+ <span class="material-symbols-outlined text-[18px]">close</span>
430
+ </button>
178
431
  </div>
432
+ <div class="structure-graph__title">${escapeHtml(tableData.name)}</div>
179
433
  </div>
180
434
 
181
435
  <div class="structure-graph__summary">
@@ -239,6 +493,13 @@ export function buildGraphElements(schema) {
239
493
  return;
240
494
  }
241
495
 
496
+ const joinConditions = (foreignKey.mappings ?? []).map(mapping => ({
497
+ sourceTable: table.name,
498
+ targetTable: foreignKey.referencedTable,
499
+ sourceColumn: mapping.from || '',
500
+ targetColumn: mapping.to || 'rowid',
501
+ }));
502
+
242
503
  (foreignKey.mappings ?? []).forEach((mapping, mappingIndex) => {
243
504
  edges.push({
244
505
  group: 'edges',
@@ -259,6 +520,7 @@ export function buildGraphElements(schema) {
259
520
  targetTable: foreignKey.referencedTable,
260
521
  sourceColumn: mapping.from || '',
261
522
  targetColumn: mapping.to || 'rowid',
523
+ joinConditions,
262
524
  },
263
525
  });
264
526
  });
@@ -268,22 +530,14 @@ export function buildGraphElements(schema) {
268
530
  return [...nodes, ...edges];
269
531
  }
270
532
 
271
- export function getElkLayoutOptions(overrides = {}) {
533
+ function getReadableLayoutGeometry(variant = 0) {
272
534
  return {
273
- name: 'elk',
274
- fit: false,
275
- padding: 30,
276
- animate: false,
277
- nodeDimensionsIncludeLabels: false,
278
- elk: {
279
- algorithm: 'layered',
280
- 'elk.direction': 'RIGHT',
281
- 'elk.edgeRouting': 'ORTHOGONAL',
282
- 'elk.layered.spacing.nodeNodeBetweenLayers': 60,
283
- 'elk.spacing.nodeNode': 40,
284
- 'elk.padding': 30,
285
- },
286
- ...overrides,
535
+ connectedBaseY: 150,
536
+ isolatedY: -170,
537
+ isolatedSpacing: 380,
538
+ rankSpacing: 440,
539
+ rowSpacing: 230,
540
+ variant,
287
541
  };
288
542
  }
289
543
 
@@ -310,92 +564,112 @@ export function createCytoscapeInstance(container, elements) {
310
564
  width: 'data(width)',
311
565
  height: 'data(height)',
312
566
  label: 'data(label)',
313
- 'background-color': '#262421',
314
- 'border-color': '#8a7b34',
315
- 'border-width': 1.4,
316
- color: '#f4efe8',
567
+ 'background-color': '#302b12',
568
+ 'border-color': '#c0a31d',
569
+ 'border-width': 1.8,
570
+ color: '#fff8dc',
317
571
  'font-family': 'Space Grotesk, Inter, sans-serif',
318
- 'font-size': 13,
572
+ 'font-size': 13.5,
319
573
  'font-weight': 700,
320
574
  'text-wrap': 'ellipsis',
321
- 'text-max-width': 150,
575
+ 'text-max-width': 170,
322
576
  'text-halign': 'center',
323
577
  'text-valign': 'center',
578
+ 'text-outline-color': '#11100b',
579
+ 'text-outline-width': 1.5,
324
580
  'overlay-opacity': 0,
325
- 'transition-property': 'background-color, border-color, opacity, color',
581
+ 'transition-property': 'background-color, border-color, opacity, color, width',
326
582
  'transition-duration': '140ms',
327
583
  },
328
584
  },
329
585
  {
330
586
  selector: 'node.selected',
331
587
  style: {
332
- 'background-color': '#3b340b',
333
- 'border-color': '#fce300',
334
- 'border-width': 2.8,
335
- color: '#fff6ae',
588
+ 'background-color': '#5a4e00',
589
+ 'border-color': '#ffea00',
590
+ 'border-width': 3.2,
591
+ color: '#fffbd3',
336
592
  },
337
593
  },
338
594
  {
339
595
  selector: 'node.related',
340
596
  style: {
341
- 'background-color': '#0d2d30',
342
- 'border-color': '#2dfaff',
343
- 'border-width': 2.2,
344
- color: '#fbfffe',
597
+ 'background-color': '#06454a',
598
+ 'border-color': '#1ef6ff',
599
+ 'border-width': 2.8,
600
+ color: '#edfeff',
345
601
  },
346
602
  },
347
603
  {
348
604
  selector: 'node.dimmed',
349
605
  style: {
350
- opacity: 0.24,
606
+ opacity: 0.46,
351
607
  },
352
608
  },
353
609
  {
354
610
  selector: 'edge',
355
611
  style: {
356
- width: 1.6,
612
+ width: 2.2,
357
613
  label: 'data(label)',
358
- color: '#e7dfbd',
614
+ color: '#fff5b8',
359
615
  'font-family': 'Roboto Mono, monospace',
360
- 'font-size': 8,
361
- 'text-background-color': '#171715',
362
- 'text-background-opacity': 0.94,
363
- 'text-background-padding': 4,
364
- 'line-color': '#8a7b34',
365
- 'target-arrow-color': '#8a7b34',
616
+ 'font-size': 9.5,
617
+ 'font-weight': 700,
618
+ 'text-background-color': '#050706',
619
+ 'text-background-opacity': 0.98,
620
+ 'text-background-padding': 5,
621
+ 'text-background-shape': 'roundrectangle',
622
+ 'text-border-color': '#3d3510',
623
+ 'text-border-opacity': 0.9,
624
+ 'text-border-width': 1,
625
+ 'text-outline-color': '#050706',
626
+ 'text-outline-width': 1.8,
627
+ 'text-wrap': 'wrap',
628
+ 'text-max-width': 170,
629
+ 'text-margin-y': -7,
630
+ 'line-color': '#c8a81d',
631
+ 'target-arrow-color': '#c8a81d',
632
+ 'arrow-scale': 1.18,
366
633
  'target-arrow-shape': 'triangle',
367
- 'curve-style': 'taxi',
368
- 'taxi-direction': 'rightward',
369
- 'taxi-turn': 20,
634
+ 'curve-style': 'bezier',
635
+ 'control-point-step-size': 82,
636
+ 'loop-direction': '45deg',
637
+ 'loop-sweep': '90deg',
370
638
  'source-endpoint': 'outside-to-node',
371
639
  'target-endpoint': 'outside-to-node',
372
640
  'overlay-opacity': 0,
373
- 'transition-property': 'line-color, target-arrow-color, opacity, color',
641
+ 'z-index': 1,
642
+ 'transition-property': 'line-color, target-arrow-color, opacity, color, width',
374
643
  'transition-duration': '120ms',
375
644
  },
376
645
  },
377
646
  {
378
647
  selector: 'edge.related',
379
648
  style: {
380
- 'line-color': '#2dfaff',
381
- 'target-arrow-color': '#2dfaff',
382
- color: '#fbfffe',
383
- width: 2.3,
649
+ 'line-color': '#1ef6ff',
650
+ 'target-arrow-color': '#1ef6ff',
651
+ color: '#edfeff',
652
+ 'text-border-color': '#1ef6ff',
653
+ width: 3.1,
654
+ 'z-index': 24,
384
655
  },
385
656
  },
386
657
  {
387
658
  selector: 'edge.hovered',
388
659
  style: {
389
- 'line-color': '#fce300',
390
- 'target-arrow-color': '#fce300',
391
- color: '#fff6ae',
392
- width: 2.8,
660
+ 'line-color': '#ffea00',
661
+ 'target-arrow-color': '#ffea00',
662
+ 'arrow-scale': 1.35,
663
+ color: '#fffbd3',
664
+ 'text-border-color': '#ffea00',
665
+ width: 3.6,
666
+ 'z-index': 32,
393
667
  },
394
668
  },
395
669
  {
396
670
  selector: 'edge.dimmed',
397
671
  style: {
398
- opacity: 0.16,
672
+ opacity: 0.36,
399
673
  },
400
674
  },
401
675
  ],
@@ -410,6 +684,129 @@ export function resetHighlights(cy) {
410
684
  cy.elements().removeClass('selected related hovered dimmed');
411
685
  }
412
686
 
687
+ function formatQualifiedEdgeColumn(tableName, columnName) {
688
+ const safeTableName = String(tableName || '?');
689
+ const safeColumnName = String(columnName || 'rowid');
690
+
691
+ return `${safeTableName}.${safeColumnName}`;
692
+ }
693
+
694
+ function positionEdgeReadout(renderedPosition) {
695
+ if (!currentGraph?.edgeReadout || !currentGraph.canvasShell || !renderedPosition) {
696
+ return;
697
+ }
698
+
699
+ const readout = currentGraph.edgeReadout;
700
+ const shell = currentGraph.canvasShell;
701
+ const width = readout.offsetWidth || 280;
702
+ const height = readout.offsetHeight || 64;
703
+ const maxLeft = Math.max(12, shell.clientWidth - width - 12);
704
+ const maxTop = Math.max(12, shell.clientHeight - height - 12);
705
+ const left = Math.min(Math.max(renderedPosition.x + 16, 12), maxLeft);
706
+ const top = Math.min(Math.max(renderedPosition.y - height / 2, 12), maxTop);
707
+
708
+ readout.style.left = `${left}px`;
709
+ readout.style.top = `${top}px`;
710
+ }
711
+
712
+ function showEdgeReadout(edge, renderedPosition) {
713
+ if (!currentGraph?.edgeReadout || !edge) {
714
+ return;
715
+ }
716
+
717
+ const sourceTable = edge.data('sourceTable') || edge.source().data('tableName') || '?';
718
+ const targetTable = edge.data('targetTable') || edge.target().data('tableName') || '?';
719
+ const sourceColumn = edge.data('sourceColumn') || '?';
720
+ const targetColumn = edge.data('targetColumn') || 'rowid';
721
+ const sourceLabel = formatQualifiedEdgeColumn(sourceTable, sourceColumn);
722
+ const targetLabel = formatQualifiedEdgeColumn(targetTable, targetColumn);
723
+
724
+ currentGraph.edgeReadout.innerHTML = `
725
+ <div class="structure-graph__edge-readout-meta">
726
+ ${escapeHtml(sourceTable)} <span aria-hidden="true">→</span> ${escapeHtml(targetTable)}
727
+ </div>
728
+ <div class="structure-graph__edge-readout-path">
729
+ ${escapeHtml(sourceLabel)} <span aria-hidden="true">→</span> ${escapeHtml(targetLabel)}
730
+ </div>
731
+ `;
732
+ currentGraph.edgeReadout.removeAttribute('hidden');
733
+ positionEdgeReadout(renderedPosition);
734
+ }
735
+
736
+ function hideEdgeReadout() {
737
+ if (!currentGraph?.edgeReadout) {
738
+ return;
739
+ }
740
+
741
+ currentGraph.edgeReadout.setAttribute('hidden', 'hidden');
742
+ }
743
+
744
+ function createGraphStateSnapshot(graph = currentGraph) {
745
+ if (!graph?.cy) {
746
+ return null;
747
+ }
748
+
749
+ const nodePositions = {};
750
+
751
+ graph.cy.nodes().forEach(node => {
752
+ const x = node.position('x');
753
+ const y = node.position('y');
754
+
755
+ if (Number.isFinite(x) && Number.isFinite(y)) {
756
+ nodePositions[node.id()] = { x, y };
757
+ }
758
+ });
759
+
760
+ if (!Object.keys(nodePositions).length) {
761
+ return null;
762
+ }
763
+
764
+ return {
765
+ schemaSignature: getSchemaSignature(graph.schema),
766
+ nodePositions,
767
+ pan: graph.cy.pan(),
768
+ zoom: graph.cy.zoom(),
769
+ inspectorHidden: graph.inspectorHidden,
770
+ selectedTableName: graph.selectedTableName,
771
+ layoutVariant: graph.layoutVariant ?? 0,
772
+ };
773
+ }
774
+
775
+ function persistCurrentGraphState({ persistToStorage = true } = {}) {
776
+ const graphState = createGraphStateSnapshot();
777
+
778
+ if (!graphState) {
779
+ return false;
780
+ }
781
+
782
+ persistedGraphState = graphState;
783
+
784
+ if (persistToStorage) {
785
+ writeStoredGraphState(graphState);
786
+ }
787
+
788
+ return true;
789
+ }
790
+
791
+ function scheduleGraphStatePersist(delay = 180) {
792
+ if (!currentGraph || currentGraph.persistStateOnDestroy === false) {
793
+ return;
794
+ }
795
+
796
+ if (currentGraph.persistTimer) {
797
+ window.clearTimeout(currentGraph.persistTimer);
798
+ }
799
+
800
+ currentGraph.persistTimer = window.setTimeout(() => {
801
+ if (!currentGraph || currentGraph.persistStateOnDestroy === false) {
802
+ return;
803
+ }
804
+
805
+ currentGraph.persistTimer = null;
806
+ persistCurrentGraphState();
807
+ }, delay);
808
+ }
809
+
413
810
  export function highlightConnectedElements(cy, element) {
414
811
  if (!cy || !element) {
415
812
  return;
@@ -446,27 +843,17 @@ function destroyCurrentGraph() {
446
843
  }
447
844
 
448
845
  if (currentGraph.cy && currentGraph.persistStateOnDestroy !== false) {
449
- const nodePositions = {};
450
- currentGraph.cy.nodes().forEach(node => {
451
- nodePositions[node.id()] = {
452
- x: node.position('x'),
453
- y: node.position('y'),
454
- };
455
- });
456
-
457
- persistedGraphState = {
458
- schemaSignature: getSchemaSignature(currentGraph.schema),
459
- nodePositions,
460
- pan: currentGraph.cy.pan(),
461
- zoom: currentGraph.cy.zoom(),
462
- inspectorHidden: currentGraph.inspectorHidden,
463
- selectedTableName: currentGraph.selectedTableName,
464
- };
846
+ persistCurrentGraphState();
465
847
  }
466
848
 
467
849
  currentGraph.cleanup.forEach(cleanup => cleanup());
468
850
  currentGraph.cleanup = [];
469
851
 
852
+ if (currentGraph.persistTimer) {
853
+ window.clearTimeout(currentGraph.persistTimer);
854
+ currentGraph.persistTimer = null;
855
+ }
856
+
470
857
  if (currentGraph.resizeObserver) {
471
858
  currentGraph.resizeObserver.disconnect();
472
859
  }
@@ -483,6 +870,10 @@ function destroyCurrentGraph() {
483
870
  }
484
871
 
485
872
  export function resetPersistedStructureGraphState() {
873
+ if (currentGraph?.persistStateOnDestroy !== false) {
874
+ persistCurrentGraphState();
875
+ }
876
+
486
877
  persistedGraphState = null;
487
878
 
488
879
  if (currentGraph) {
@@ -543,6 +934,23 @@ async function copyInspectorDdl(button) {
543
934
  }
544
935
  }
545
936
 
937
+ async function copyJoinSql(button) {
938
+ const sqlNode = button?.closest('.structure-graph__section')?.querySelector('[data-structure-graph-join-sql]');
939
+ const sql = sqlNode?.textContent ?? '';
940
+
941
+ if (!sql.trim()) {
942
+ showToast('No join SQL available to copy.', 'alert');
943
+ return;
944
+ }
945
+
946
+ try {
947
+ await navigator.clipboard.writeText(sql);
948
+ showToast('Join SQL copied.', 'success');
949
+ } catch (error) {
950
+ showToast('Clipboard access failed.', 'alert');
951
+ }
952
+ }
953
+
546
954
  function syncInspectorLayout() {
547
955
  if (!currentGraph?.root) {
548
956
  return;
@@ -571,7 +979,9 @@ function clearSelection() {
571
979
  return;
572
980
  }
573
981
 
982
+ hideEdgeReadout();
574
983
  currentGraph.selectedTableName = null;
984
+ currentGraph.selectedEdgeId = null;
575
985
  resetHighlights(currentGraph.cy);
576
986
  syncEntryHighlights();
577
987
  replaceChildrenFromRenderedMarkup(currentGraph.inspector, getDefaultInspectorMarkup());
@@ -583,7 +993,9 @@ function applyTableSelection(node, { focus = true } = {}) {
583
993
  return null;
584
994
  }
585
995
 
996
+ hideEdgeReadout();
586
997
  const tableData = node.data('table');
998
+ currentGraph.selectedEdgeId = null;
587
999
  currentGraph.selectedTableName = tableData.name;
588
1000
  replaceChildrenFromRenderedMarkup(currentGraph.inspector, renderInspector(tableData));
589
1001
  highlightConnectedElements(currentGraph.cy, node);
@@ -608,6 +1020,21 @@ function applyTableSelection(node, { focus = true } = {}) {
608
1020
  return node;
609
1021
  }
610
1022
 
1023
+ function applyEdgeSelection(edge) {
1024
+ if (!currentGraph || !edge) {
1025
+ return null;
1026
+ }
1027
+
1028
+ hideEdgeReadout();
1029
+ currentGraph.selectedTableName = null;
1030
+ currentGraph.selectedEdgeId = edge.id();
1031
+ replaceChildrenFromRenderedMarkup(currentGraph.inspector, renderJoinInspector(edge.data()));
1032
+ highlightConnectedElements(currentGraph.cy, edge);
1033
+ syncEntryHighlights([], [edge.data('sourceTable'), edge.data('targetTable')]);
1034
+ updateOpenDataButton();
1035
+ return edge;
1036
+ }
1037
+
611
1038
  function restoreGraphState() {
612
1039
  if (!currentGraph) {
613
1040
  return;
@@ -623,24 +1050,163 @@ function restoreGraphState() {
623
1050
  }
624
1051
  }
625
1052
 
1053
+ if (currentGraph.selectedEdgeId) {
1054
+ const selectedEdge = currentGraph.cy.getElementById(currentGraph.selectedEdgeId);
1055
+
1056
+ if (selectedEdge.nonempty()) {
1057
+ highlightConnectedElements(currentGraph.cy, selectedEdge);
1058
+ syncEntryHighlights([], [selectedEdge.data('sourceTable'), selectedEdge.data('targetTable')]);
1059
+ return;
1060
+ }
1061
+ }
1062
+
626
1063
  resetHighlights(currentGraph.cy);
627
1064
  syncEntryHighlights();
628
1065
  updateOpenDataButton();
629
1066
  }
630
1067
 
631
- function runLayout(cy, onStop) {
632
- const layout = cy.layout(
633
- getElkLayoutOptions({
634
- stop: () => {
635
- cy.fit(cy.elements(), 60);
636
- if (typeof onStop === 'function') {
637
- onStop();
638
- }
639
- },
640
- }),
641
- );
1068
+ function getSortedGraphNodes(nodes) {
1069
+ return nodes
1070
+ .toArray()
1071
+ .sort((left, right) => String(left.data('tableName')).localeCompare(String(right.data('tableName'))));
1072
+ }
1073
+
1074
+ function getIncomingAverageY(node) {
1075
+ const incomingY = [];
1076
+
1077
+ node.incomers('edge').forEach(edge => {
1078
+ const y = edge.source().position('y');
1079
+
1080
+ if (Number.isFinite(y)) {
1081
+ incomingY.push(y);
1082
+ }
1083
+ });
642
1084
 
643
- layout.run();
1085
+ if (!incomingY.length) {
1086
+ return null;
1087
+ }
1088
+
1089
+ return incomingY.reduce((sum, value) => sum + value, 0) / incomingY.length;
1090
+ }
1091
+
1092
+ function getLayoutVariantOffset(variant, rank, index, axis) {
1093
+ if (!variant) {
1094
+ return 0;
1095
+ }
1096
+
1097
+ const factor = axis === 'x' ? 28 : 46;
1098
+ const wave = axis === 'x' ? Math.cos : Math.sin;
1099
+
1100
+ return wave((variant + 1) * (rank + 1.7) * (index + 2.3)) * factor;
1101
+ }
1102
+
1103
+ function applyReadableLayoutPositions(cy, { variant = 0 } = {}) {
1104
+ const nodes = getSortedGraphNodes(cy.nodes());
1105
+
1106
+ if (!nodes.length) {
1107
+ return;
1108
+ }
1109
+
1110
+ const geometry = getReadableLayoutGeometry(variant);
1111
+ const rankById = new Map(nodes.map(node => [node.id(), 0]));
1112
+
1113
+ for (let iteration = 0; iteration < nodes.length; iteration += 1) {
1114
+ let changed = false;
1115
+
1116
+ cy.edges().forEach(edge => {
1117
+ const source = edge.source();
1118
+ const target = edge.target();
1119
+
1120
+ if (source.id() === target.id()) {
1121
+ return;
1122
+ }
1123
+
1124
+ const sourceRank = rankById.get(source.id()) ?? 0;
1125
+ const targetRank = rankById.get(target.id()) ?? 0;
1126
+
1127
+ if (targetRank < sourceRank + 1) {
1128
+ rankById.set(target.id(), sourceRank + 1);
1129
+ changed = true;
1130
+ }
1131
+ });
1132
+
1133
+ if (!changed) {
1134
+ break;
1135
+ }
1136
+ }
1137
+
1138
+ const connectedNodes = nodes.filter(node => node.connectedEdges().nonempty());
1139
+ const isolatedNodes = nodes.filter(node => node.connectedEdges().empty());
1140
+ const nodesByRank = new Map();
1141
+
1142
+ connectedNodes.forEach(node => {
1143
+ const rank = rankById.get(node.id()) ?? 0;
1144
+ const group = nodesByRank.get(rank) ?? [];
1145
+
1146
+ group.push(node);
1147
+ nodesByRank.set(rank, group);
1148
+ });
1149
+
1150
+ const ranks = [...nodesByRank.keys()].sort((left, right) => left - right);
1151
+ const maxRank = ranks.at(-1) ?? 0;
1152
+ const connectedBaseX = -(maxRank * geometry.rankSpacing) / 2;
1153
+
1154
+ ranks.forEach(rank => {
1155
+ const group = (nodesByRank.get(rank) ?? []).sort((left, right) => {
1156
+ const leftAverageY = getIncomingAverageY(left);
1157
+ const rightAverageY = getIncomingAverageY(right);
1158
+
1159
+ if (leftAverageY !== null && rightAverageY !== null && leftAverageY !== rightAverageY) {
1160
+ return leftAverageY - rightAverageY;
1161
+ }
1162
+
1163
+ const degreeDelta = right.degree(false) - left.degree(false);
1164
+
1165
+ if (degreeDelta !== 0) {
1166
+ return degreeDelta;
1167
+ }
1168
+
1169
+ return String(left.data('tableName')).localeCompare(String(right.data('tableName')));
1170
+ });
1171
+
1172
+ group.forEach((node, index) => {
1173
+ node.position({
1174
+ x:
1175
+ connectedBaseX +
1176
+ rank * geometry.rankSpacing +
1177
+ getLayoutVariantOffset(geometry.variant, rank, index, 'x'),
1178
+ y:
1179
+ geometry.connectedBaseY +
1180
+ (index - (group.length - 1) / 2) * geometry.rowSpacing +
1181
+ (rank % 2 === 0 ? 0 : 58) +
1182
+ getLayoutVariantOffset(geometry.variant, rank, index, 'y'),
1183
+ });
1184
+ });
1185
+ });
1186
+
1187
+ isolatedNodes.forEach((node, index) => {
1188
+ node.position({
1189
+ x:
1190
+ (index - (isolatedNodes.length - 1) / 2) * geometry.isolatedSpacing +
1191
+ getLayoutVariantOffset(geometry.variant, -1, index, 'x'),
1192
+ y: geometry.isolatedY + getLayoutVariantOffset(geometry.variant, -1, index, 'y'),
1193
+ });
1194
+ });
1195
+ }
1196
+
1197
+ function runLayout(cy, onStop, { randomize = false } = {}) {
1198
+ if (currentGraph) {
1199
+ currentGraph.layoutVariant = randomize ? (currentGraph.layoutVariant ?? 0) + 1 : 0;
1200
+ }
1201
+
1202
+ applyReadableLayoutPositions(cy, { variant: currentGraph?.layoutVariant ?? 0 });
1203
+ cy.fit(cy.elements(), 90);
1204
+
1205
+ if (typeof onStop === 'function') {
1206
+ onStop();
1207
+ }
1208
+
1209
+ persistCurrentGraphState();
644
1210
  }
645
1211
 
646
1212
  function restorePersistedViewport(cy, persistedState) {
@@ -696,16 +1262,18 @@ export function setupToolbar(cy) {
696
1262
  switch (button.dataset.structureGraphAction) {
697
1263
  case 'fit':
698
1264
  cy.fit(cy.elements(), 60);
1265
+ scheduleGraphStatePersist();
699
1266
  break;
700
1267
  case 'relayout':
1268
+ hideEdgeReadout();
701
1269
  runLayout(cy, () => {
702
1270
  if (currentGraph?.selectedTableName) {
703
1271
  const selectedNode = cy.getElementById(getTableId(currentGraph.selectedTableName));
704
1272
  if (selectedNode.nonempty()) {
705
- applyTableSelection(selectedNode, { focus: true });
1273
+ applyTableSelection(selectedNode, { focus: false });
706
1274
  }
707
1275
  }
708
- });
1276
+ }, { randomize: true });
709
1277
  break;
710
1278
  case 'clear':
711
1279
  clearSelection();
@@ -720,10 +1288,14 @@ export function setupToolbar(cy) {
720
1288
  storeInspectorHidden(currentGraph.inspectorHidden);
721
1289
  updateInspectorToggleButton();
722
1290
  syncInspectorLayout();
1291
+ scheduleGraphStatePersist();
723
1292
  break;
724
1293
  case 'copy-ddl':
725
1294
  await copyInspectorDdl(button);
726
1295
  break;
1296
+ case 'copy-join-sql':
1297
+ await copyJoinSql(button);
1298
+ break;
727
1299
  default:
728
1300
  }
729
1301
  };
@@ -762,6 +1334,8 @@ export async function mountStructureGraph(snapshot) {
762
1334
  const canvas = root.querySelector('[data-structure-graph-canvas]');
763
1335
  const inspector = root.querySelector('[data-structure-graph-inspector]');
764
1336
  const empty = root.querySelector('[data-structure-graph-empty]');
1337
+ const canvasShell = canvas?.closest('.structure-graph__canvas-shell');
1338
+ const edgeReadout = root.querySelector('[data-structure-graph-edge-readout]');
765
1339
  const schema = snapshot.structure.data?.graph ?? { tables: [] };
766
1340
  const { schemaSignature, state: cachedState } = getPersistedGraphState(schema);
767
1341
 
@@ -782,6 +1356,8 @@ export async function mountStructureGraph(snapshot) {
782
1356
  currentGraph = {
783
1357
  root,
784
1358
  canvas,
1359
+ canvasShell: canvasShell instanceof HTMLElement ? canvasShell : null,
1360
+ edgeReadout: edgeReadout instanceof HTMLElement ? edgeReadout : null,
785
1361
  inspector,
786
1362
  initialInspectorMarkup: inspector.innerHTML,
787
1363
  initialSelectedTableName: null,
@@ -794,14 +1370,26 @@ export async function mountStructureGraph(snapshot) {
794
1370
  openDataButton: null,
795
1371
  inspectorToggleButton: null,
796
1372
  inspectorHidden: cachedState?.inspectorHidden ?? readStoredInspectorHidden(),
1373
+ layoutVariant: cachedState?.layoutVariant ?? 0,
797
1374
  persistStateOnDestroy: true,
1375
+ selectedEdgeId: null,
798
1376
  selectedTableName: null,
799
1377
  };
800
1378
 
801
1379
  currentGraph.cleanup.push(setupToolbar(cy));
802
1380
 
1381
+ const persistBeforeUnload = () => {
1382
+ persistCurrentGraphState();
1383
+ };
1384
+
1385
+ window.addEventListener('beforeunload', persistBeforeUnload);
1386
+ currentGraph.cleanup.push(() => {
1387
+ window.removeEventListener('beforeunload', persistBeforeUnload);
1388
+ });
1389
+
803
1390
  cy.on('tap', 'node', event => {
804
1391
  applyTableSelection(event.target, { focus: false });
1392
+ scheduleGraphStatePersist();
805
1393
  });
806
1394
 
807
1395
  cy.on('tap', event => {
@@ -810,16 +1398,38 @@ export async function mountStructureGraph(snapshot) {
810
1398
  }
811
1399
  });
812
1400
 
1401
+ cy.on('tap', 'edge', event => {
1402
+ applyEdgeSelection(event.target);
1403
+ showEdgeReadout(event.target, event.renderedPosition);
1404
+ updateOpenDataButton();
1405
+ scheduleGraphStatePersist();
1406
+ });
1407
+
1408
+ cy.on('dragfree', 'node', () => {
1409
+ scheduleGraphStatePersist(0);
1410
+ });
1411
+
813
1412
  cy.on('mouseover', 'edge', event => {
814
1413
  const edge = event.target;
815
1414
  highlightConnectedElements(cy, edge);
816
1415
  syncEntryHighlights([], [edge.data('sourceTable'), edge.data('targetTable')]);
1416
+ showEdgeReadout(edge, event.renderedPosition);
1417
+ });
1418
+
1419
+ cy.on('mousemove', 'edge', event => {
1420
+ showEdgeReadout(event.target, event.renderedPosition);
817
1421
  });
818
1422
 
819
1423
  cy.on('mouseout', 'edge', () => {
1424
+ hideEdgeReadout();
820
1425
  restoreGraphState();
821
1426
  });
822
1427
 
1428
+ cy.on('pan zoom', () => {
1429
+ hideEdgeReadout();
1430
+ scheduleGraphStatePersist(300);
1431
+ });
1432
+
823
1433
  const selectedTableName = snapshot.structure.data?.grouped?.tables?.some(
824
1434
  table => table.name === snapshot.structure.selectedName,
825
1435
  )