sqlite-hub 1.0.0 → 1.1.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 (47) 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 +229 -44
  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 +24 -26
  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 +700 -89
  13. package/frontend/js/components/tableDesignerEditor.js +26 -47
  14. package/frontend/js/components/tableDesignerSidebar.js +7 -31
  15. package/frontend/js/components/tableDesignerSqlPreview.js +2 -1
  16. package/frontend/js/store.js +97 -8
  17. package/frontend/js/utils/exportFilenames.js +3 -1
  18. package/frontend/js/views/charts.js +326 -174
  19. package/frontend/js/views/connections.js +59 -64
  20. package/frontend/js/views/data.js +48 -54
  21. package/frontend/js/views/documents.js +62 -23
  22. package/frontend/js/views/editor.js +0 -2
  23. package/frontend/js/views/mediaTagging.js +6 -5
  24. package/frontend/js/views/settings.js +78 -0
  25. package/frontend/js/views/structure.js +48 -32
  26. package/frontend/js/views/tableDesigner.js +45 -1
  27. package/frontend/styles/components.css +259 -54
  28. package/frontend/styles/structure-graph.css +150 -37
  29. package/frontend/styles/tailwind.generated.css +1 -1
  30. package/frontend/styles/tokens.css +2 -0
  31. package/frontend/styles/views.css +75 -38
  32. package/package.json +3 -2
  33. package/server/routes/export.js +89 -22
  34. package/server/routes/externalApi.js +84 -2
  35. package/server/routes/settings.js +37 -28
  36. package/server/services/appInfoService.js +215 -0
  37. package/server/services/databaseCommandService.js +50 -6
  38. package/server/services/sqlite/dataBrowserService.js +11 -3
  39. package/server/services/sqlite/exportService.js +307 -22
  40. package/tests/api-token-auth.test.js +110 -1
  41. package/tests/cli-args.test.js +16 -3
  42. package/tests/database-command-service.test.js +40 -3
  43. package/tests/export-blob.test.js +54 -1
  44. package/tests/export-filenames.test.js +4 -0
  45. package/tests/settings-api-tokens-route.test.js +22 -1
  46. package/tests/settings-metadata.test.js +99 -1
  47. 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;
@@ -558,6 +966,7 @@ function updateInspectorToggleButton() {
558
966
  }
559
967
 
560
968
  currentGraph.inspectorToggleButton.classList.toggle('is-active', currentGraph.inspectorHidden);
969
+ currentGraph.inspectorToggleButton.setAttribute('aria-pressed', currentGraph.inspectorHidden ? 'true' : 'false');
561
970
  const icon = document.createElement('span');
562
971
  const label = currentGraph.inspectorHidden ? 'Show Inspector' : 'Hide Inspector';
563
972
 
@@ -571,7 +980,9 @@ function clearSelection() {
571
980
  return;
572
981
  }
573
982
 
983
+ hideEdgeReadout();
574
984
  currentGraph.selectedTableName = null;
985
+ currentGraph.selectedEdgeId = null;
575
986
  resetHighlights(currentGraph.cy);
576
987
  syncEntryHighlights();
577
988
  replaceChildrenFromRenderedMarkup(currentGraph.inspector, getDefaultInspectorMarkup());
@@ -583,7 +994,9 @@ function applyTableSelection(node, { focus = true } = {}) {
583
994
  return null;
584
995
  }
585
996
 
997
+ hideEdgeReadout();
586
998
  const tableData = node.data('table');
999
+ currentGraph.selectedEdgeId = null;
587
1000
  currentGraph.selectedTableName = tableData.name;
588
1001
  replaceChildrenFromRenderedMarkup(currentGraph.inspector, renderInspector(tableData));
589
1002
  highlightConnectedElements(currentGraph.cy, node);
@@ -608,6 +1021,21 @@ function applyTableSelection(node, { focus = true } = {}) {
608
1021
  return node;
609
1022
  }
610
1023
 
1024
+ function applyEdgeSelection(edge) {
1025
+ if (!currentGraph || !edge) {
1026
+ return null;
1027
+ }
1028
+
1029
+ hideEdgeReadout();
1030
+ currentGraph.selectedTableName = null;
1031
+ currentGraph.selectedEdgeId = edge.id();
1032
+ replaceChildrenFromRenderedMarkup(currentGraph.inspector, renderJoinInspector(edge.data()));
1033
+ highlightConnectedElements(currentGraph.cy, edge);
1034
+ syncEntryHighlights([], [edge.data('sourceTable'), edge.data('targetTable')]);
1035
+ updateOpenDataButton();
1036
+ return edge;
1037
+ }
1038
+
611
1039
  function restoreGraphState() {
612
1040
  if (!currentGraph) {
613
1041
  return;
@@ -623,24 +1051,163 @@ function restoreGraphState() {
623
1051
  }
624
1052
  }
625
1053
 
1054
+ if (currentGraph.selectedEdgeId) {
1055
+ const selectedEdge = currentGraph.cy.getElementById(currentGraph.selectedEdgeId);
1056
+
1057
+ if (selectedEdge.nonempty()) {
1058
+ highlightConnectedElements(currentGraph.cy, selectedEdge);
1059
+ syncEntryHighlights([], [selectedEdge.data('sourceTable'), selectedEdge.data('targetTable')]);
1060
+ return;
1061
+ }
1062
+ }
1063
+
626
1064
  resetHighlights(currentGraph.cy);
627
1065
  syncEntryHighlights();
628
1066
  updateOpenDataButton();
629
1067
  }
630
1068
 
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
- );
1069
+ function getSortedGraphNodes(nodes) {
1070
+ return nodes
1071
+ .toArray()
1072
+ .sort((left, right) => String(left.data('tableName')).localeCompare(String(right.data('tableName'))));
1073
+ }
1074
+
1075
+ function getIncomingAverageY(node) {
1076
+ const incomingY = [];
1077
+
1078
+ node.incomers('edge').forEach(edge => {
1079
+ const y = edge.source().position('y');
1080
+
1081
+ if (Number.isFinite(y)) {
1082
+ incomingY.push(y);
1083
+ }
1084
+ });
642
1085
 
643
- layout.run();
1086
+ if (!incomingY.length) {
1087
+ return null;
1088
+ }
1089
+
1090
+ return incomingY.reduce((sum, value) => sum + value, 0) / incomingY.length;
1091
+ }
1092
+
1093
+ function getLayoutVariantOffset(variant, rank, index, axis) {
1094
+ if (!variant) {
1095
+ return 0;
1096
+ }
1097
+
1098
+ const factor = axis === 'x' ? 28 : 46;
1099
+ const wave = axis === 'x' ? Math.cos : Math.sin;
1100
+
1101
+ return wave((variant + 1) * (rank + 1.7) * (index + 2.3)) * factor;
1102
+ }
1103
+
1104
+ function applyReadableLayoutPositions(cy, { variant = 0 } = {}) {
1105
+ const nodes = getSortedGraphNodes(cy.nodes());
1106
+
1107
+ if (!nodes.length) {
1108
+ return;
1109
+ }
1110
+
1111
+ const geometry = getReadableLayoutGeometry(variant);
1112
+ const rankById = new Map(nodes.map(node => [node.id(), 0]));
1113
+
1114
+ for (let iteration = 0; iteration < nodes.length; iteration += 1) {
1115
+ let changed = false;
1116
+
1117
+ cy.edges().forEach(edge => {
1118
+ const source = edge.source();
1119
+ const target = edge.target();
1120
+
1121
+ if (source.id() === target.id()) {
1122
+ return;
1123
+ }
1124
+
1125
+ const sourceRank = rankById.get(source.id()) ?? 0;
1126
+ const targetRank = rankById.get(target.id()) ?? 0;
1127
+
1128
+ if (targetRank < sourceRank + 1) {
1129
+ rankById.set(target.id(), sourceRank + 1);
1130
+ changed = true;
1131
+ }
1132
+ });
1133
+
1134
+ if (!changed) {
1135
+ break;
1136
+ }
1137
+ }
1138
+
1139
+ const connectedNodes = nodes.filter(node => node.connectedEdges().nonempty());
1140
+ const isolatedNodes = nodes.filter(node => node.connectedEdges().empty());
1141
+ const nodesByRank = new Map();
1142
+
1143
+ connectedNodes.forEach(node => {
1144
+ const rank = rankById.get(node.id()) ?? 0;
1145
+ const group = nodesByRank.get(rank) ?? [];
1146
+
1147
+ group.push(node);
1148
+ nodesByRank.set(rank, group);
1149
+ });
1150
+
1151
+ const ranks = [...nodesByRank.keys()].sort((left, right) => left - right);
1152
+ const maxRank = ranks.at(-1) ?? 0;
1153
+ const connectedBaseX = -(maxRank * geometry.rankSpacing) / 2;
1154
+
1155
+ ranks.forEach(rank => {
1156
+ const group = (nodesByRank.get(rank) ?? []).sort((left, right) => {
1157
+ const leftAverageY = getIncomingAverageY(left);
1158
+ const rightAverageY = getIncomingAverageY(right);
1159
+
1160
+ if (leftAverageY !== null && rightAverageY !== null && leftAverageY !== rightAverageY) {
1161
+ return leftAverageY - rightAverageY;
1162
+ }
1163
+
1164
+ const degreeDelta = right.degree(false) - left.degree(false);
1165
+
1166
+ if (degreeDelta !== 0) {
1167
+ return degreeDelta;
1168
+ }
1169
+
1170
+ return String(left.data('tableName')).localeCompare(String(right.data('tableName')));
1171
+ });
1172
+
1173
+ group.forEach((node, index) => {
1174
+ node.position({
1175
+ x:
1176
+ connectedBaseX +
1177
+ rank * geometry.rankSpacing +
1178
+ getLayoutVariantOffset(geometry.variant, rank, index, 'x'),
1179
+ y:
1180
+ geometry.connectedBaseY +
1181
+ (index - (group.length - 1) / 2) * geometry.rowSpacing +
1182
+ (rank % 2 === 0 ? 0 : 58) +
1183
+ getLayoutVariantOffset(geometry.variant, rank, index, 'y'),
1184
+ });
1185
+ });
1186
+ });
1187
+
1188
+ isolatedNodes.forEach((node, index) => {
1189
+ node.position({
1190
+ x:
1191
+ (index - (isolatedNodes.length - 1) / 2) * geometry.isolatedSpacing +
1192
+ getLayoutVariantOffset(geometry.variant, -1, index, 'x'),
1193
+ y: geometry.isolatedY + getLayoutVariantOffset(geometry.variant, -1, index, 'y'),
1194
+ });
1195
+ });
1196
+ }
1197
+
1198
+ function runLayout(cy, onStop, { randomize = false } = {}) {
1199
+ if (currentGraph) {
1200
+ currentGraph.layoutVariant = randomize ? (currentGraph.layoutVariant ?? 0) + 1 : 0;
1201
+ }
1202
+
1203
+ applyReadableLayoutPositions(cy, { variant: currentGraph?.layoutVariant ?? 0 });
1204
+ cy.fit(cy.elements(), 90);
1205
+
1206
+ if (typeof onStop === 'function') {
1207
+ onStop();
1208
+ }
1209
+
1210
+ persistCurrentGraphState();
644
1211
  }
645
1212
 
646
1213
  function restorePersistedViewport(cy, persistedState) {
@@ -696,16 +1263,18 @@ export function setupToolbar(cy) {
696
1263
  switch (button.dataset.structureGraphAction) {
697
1264
  case 'fit':
698
1265
  cy.fit(cy.elements(), 60);
1266
+ scheduleGraphStatePersist();
699
1267
  break;
700
1268
  case 'relayout':
1269
+ hideEdgeReadout();
701
1270
  runLayout(cy, () => {
702
1271
  if (currentGraph?.selectedTableName) {
703
1272
  const selectedNode = cy.getElementById(getTableId(currentGraph.selectedTableName));
704
1273
  if (selectedNode.nonempty()) {
705
- applyTableSelection(selectedNode, { focus: true });
1274
+ applyTableSelection(selectedNode, { focus: false });
706
1275
  }
707
1276
  }
708
- });
1277
+ }, { randomize: true });
709
1278
  break;
710
1279
  case 'clear':
711
1280
  clearSelection();
@@ -720,10 +1289,14 @@ export function setupToolbar(cy) {
720
1289
  storeInspectorHidden(currentGraph.inspectorHidden);
721
1290
  updateInspectorToggleButton();
722
1291
  syncInspectorLayout();
1292
+ scheduleGraphStatePersist();
723
1293
  break;
724
1294
  case 'copy-ddl':
725
1295
  await copyInspectorDdl(button);
726
1296
  break;
1297
+ case 'copy-join-sql':
1298
+ await copyJoinSql(button);
1299
+ break;
727
1300
  default:
728
1301
  }
729
1302
  };
@@ -762,6 +1335,8 @@ export async function mountStructureGraph(snapshot) {
762
1335
  const canvas = root.querySelector('[data-structure-graph-canvas]');
763
1336
  const inspector = root.querySelector('[data-structure-graph-inspector]');
764
1337
  const empty = root.querySelector('[data-structure-graph-empty]');
1338
+ const canvasShell = canvas?.closest('.structure-graph__canvas-shell');
1339
+ const edgeReadout = root.querySelector('[data-structure-graph-edge-readout]');
765
1340
  const schema = snapshot.structure.data?.graph ?? { tables: [] };
766
1341
  const { schemaSignature, state: cachedState } = getPersistedGraphState(schema);
767
1342
 
@@ -782,6 +1357,8 @@ export async function mountStructureGraph(snapshot) {
782
1357
  currentGraph = {
783
1358
  root,
784
1359
  canvas,
1360
+ canvasShell: canvasShell instanceof HTMLElement ? canvasShell : null,
1361
+ edgeReadout: edgeReadout instanceof HTMLElement ? edgeReadout : null,
785
1362
  inspector,
786
1363
  initialInspectorMarkup: inspector.innerHTML,
787
1364
  initialSelectedTableName: null,
@@ -794,14 +1371,26 @@ export async function mountStructureGraph(snapshot) {
794
1371
  openDataButton: null,
795
1372
  inspectorToggleButton: null,
796
1373
  inspectorHidden: cachedState?.inspectorHidden ?? readStoredInspectorHidden(),
1374
+ layoutVariant: cachedState?.layoutVariant ?? 0,
797
1375
  persistStateOnDestroy: true,
1376
+ selectedEdgeId: null,
798
1377
  selectedTableName: null,
799
1378
  };
800
1379
 
801
1380
  currentGraph.cleanup.push(setupToolbar(cy));
802
1381
 
1382
+ const persistBeforeUnload = () => {
1383
+ persistCurrentGraphState();
1384
+ };
1385
+
1386
+ window.addEventListener('beforeunload', persistBeforeUnload);
1387
+ currentGraph.cleanup.push(() => {
1388
+ window.removeEventListener('beforeunload', persistBeforeUnload);
1389
+ });
1390
+
803
1391
  cy.on('tap', 'node', event => {
804
1392
  applyTableSelection(event.target, { focus: false });
1393
+ scheduleGraphStatePersist();
805
1394
  });
806
1395
 
807
1396
  cy.on('tap', event => {
@@ -810,16 +1399,38 @@ export async function mountStructureGraph(snapshot) {
810
1399
  }
811
1400
  });
812
1401
 
1402
+ cy.on('tap', 'edge', event => {
1403
+ applyEdgeSelection(event.target);
1404
+ showEdgeReadout(event.target, event.renderedPosition);
1405
+ updateOpenDataButton();
1406
+ scheduleGraphStatePersist();
1407
+ });
1408
+
1409
+ cy.on('dragfree', 'node', () => {
1410
+ scheduleGraphStatePersist(0);
1411
+ });
1412
+
813
1413
  cy.on('mouseover', 'edge', event => {
814
1414
  const edge = event.target;
815
1415
  highlightConnectedElements(cy, edge);
816
1416
  syncEntryHighlights([], [edge.data('sourceTable'), edge.data('targetTable')]);
1417
+ showEdgeReadout(edge, event.renderedPosition);
1418
+ });
1419
+
1420
+ cy.on('mousemove', 'edge', event => {
1421
+ showEdgeReadout(event.target, event.renderedPosition);
817
1422
  });
818
1423
 
819
1424
  cy.on('mouseout', 'edge', () => {
1425
+ hideEdgeReadout();
820
1426
  restoreGraphState();
821
1427
  });
822
1428
 
1429
+ cy.on('pan zoom', () => {
1430
+ hideEdgeReadout();
1431
+ scheduleGraphStatePersist(300);
1432
+ });
1433
+
823
1434
  const selectedTableName = snapshot.structure.data?.grouped?.tables?.some(
824
1435
  table => table.name === snapshot.structure.selectedName,
825
1436
  )