sqlite-hub 2.0.1 → 2.2.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 (98) hide show
  1. package/README.md +21 -9
  2. package/docs/MCP.md +8 -0
  3. package/docs/changelog.md +16 -0
  4. package/docs/guidelines/DESIGN.md +64 -0
  5. package/docs/todo.md +2 -0
  6. package/frontend/assets/mockups/backups_1_1920.webp +0 -0
  7. package/frontend/assets/mockups/backups_2_compare_drawer_1920.webp +0 -0
  8. package/frontend/assets/mockups/backups_3_create_backup_modal_1920.webp +0 -0
  9. package/frontend/assets/mockups/backups_4_edit_backup_modal_1920.webp +0 -0
  10. package/frontend/assets/mockups/backups_5_restore_backup_modal_1920.webp +0 -0
  11. package/frontend/assets/mockups/backups_6_delete_backup_modal_1920.webp +0 -0
  12. package/frontend/assets/mockups/charts_1_1920.webp +0 -0
  13. package/frontend/assets/mockups/charts_2_query_detail_1920.webp +0 -0
  14. package/frontend/assets/mockups/charts_3_create_query_chart_modal_1920.webp +0 -0
  15. package/frontend/assets/mockups/charts_4_edit_query_chart_modal_1920.webp +0 -0
  16. package/frontend/assets/mockups/charts_5_delete_query_chart_modal_1920.webp +0 -0
  17. package/frontend/assets/mockups/charts_6_copy_column_modal_1920.webp +0 -0
  18. package/frontend/assets/mockups/connections_1_1920.webp +0 -0
  19. package/frontend/assets/mockups/connections_2_create_connection_modal_1920.webp +0 -0
  20. package/frontend/assets/mockups/connections_3_open_connection_modal_1920.webp +0 -0
  21. package/frontend/assets/mockups/data_1_1920.webp +0 -0
  22. package/frontend/assets/mockups/data_2_roweditor_1920.webp +0 -0
  23. package/frontend/assets/mockups/data_3_generate_data_modal_1920.webp +0 -0
  24. package/frontend/assets/mockups/data_4_data_export_modal_1920.webp +0 -0
  25. package/frontend/assets/mockups/documents_1_1920.webp +0 -0
  26. package/frontend/assets/mockups/documents_2_document_insert_table_modal_1920.webp +0 -0
  27. package/frontend/assets/mockups/documents_3_document_insert_note_modal_1920.webp +0 -0
  28. package/frontend/assets/mockups/logs_1_1920.webp +0 -0
  29. package/frontend/assets/mockups/media_tagging_queue_1_1920.webp +0 -0
  30. package/frontend/assets/mockups/media_tagging_setup_1_1920.webp +0 -0
  31. package/frontend/assets/mockups/media_tagging_setup_2_create_media_tagging_tag_table_modal_1920.webp +0 -0
  32. package/frontend/assets/mockups/media_tagging_setup_3_create_media_tagging_mapping_table_modal_1920.webp +0 -0
  33. package/frontend/assets/mockups/overview_1_1920.webp +0 -0
  34. package/frontend/assets/mockups/settings_1_1920.webp +0 -0
  35. package/frontend/assets/mockups/sql_editor_1_1920.webp +0 -0
  36. package/frontend/assets/mockups/sql_editor_2_query_detail_1920.webp +0 -0
  37. package/frontend/assets/mockups/sql_editor_3_query_export_modal_1920.webp +0 -0
  38. package/frontend/assets/mockups/structure_1_1920.webp +0 -0
  39. package/frontend/assets/mockups/structure_2_generate_types_modal_1920.webp +0 -0
  40. package/frontend/assets/mockups/structure_3_generate_types_modal_1920.webp +0 -0
  41. package/frontend/assets/mockups/table_designer_1_1920.webp +0 -0
  42. package/frontend/assets/mockups/table_designer_2_table_designer_constraints_modal_1920.webp +0 -0
  43. package/frontend/js/api.js +18 -0
  44. package/frontend/js/app.js +567 -15
  45. package/frontend/js/components/badges.js +16 -0
  46. package/frontend/js/components/connectionCard.js +35 -0
  47. package/frontend/js/components/modal.js +268 -247
  48. package/frontend/js/components/queryHistoryDetail.js +25 -45
  49. package/frontend/js/components/rowEditorPanel.js +7 -60
  50. package/frontend/js/components/structureGraph.js +292 -13
  51. package/frontend/js/components/tableDesignerConstraintsDrawer.js +400 -0
  52. package/frontend/js/components/tableDesignerEditor.js +51 -16
  53. package/frontend/js/components/tableDesignerSidebar.js +9 -5
  54. package/frontend/js/store.js +1847 -190
  55. package/frontend/js/utils/connectionRegistry.js +76 -0
  56. package/frontend/js/utils/markdownDocuments.js +21 -4
  57. package/frontend/js/utils/tableDesigner.js +431 -62
  58. package/frontend/js/views/backups.js +2 -5
  59. package/frontend/js/views/charts.js +20 -10
  60. package/frontend/js/views/connections.js +101 -6
  61. package/frontend/js/views/data.js +52 -9
  62. package/frontend/js/views/documents.js +153 -59
  63. package/frontend/js/views/structure.js +28 -5
  64. package/frontend/js/views/tableAdvisor.js +14 -6
  65. package/frontend/js/views/tableDesigner.js +44 -9
  66. package/frontend/styles/base.css +9 -0
  67. package/frontend/styles/components.css +676 -79
  68. package/frontend/styles/structure-graph.css +49 -0
  69. package/frontend/styles/tailwind.generated.css +1 -1
  70. package/frontend/styles/views.css +12 -0
  71. package/package.json +1 -1
  72. package/server/routes/backups.js +37 -1
  73. package/server/routes/charts.js +28 -0
  74. package/server/routes/connections.js +12 -0
  75. package/server/routes/data.js +30 -2
  76. package/server/routes/documents.js +61 -5
  77. package/server/routes/settings.js +32 -0
  78. package/server/routes/tableDesigner.js +35 -2
  79. package/server/server.js +3 -3
  80. package/server/services/mcpToolService.js +30 -0
  81. package/server/services/sqlite/connectionManager.js +25 -2
  82. package/server/services/sqlite/dataBrowserService.js +21 -0
  83. package/server/services/sqlite/introspection.js +39 -1
  84. package/server/services/sqlite/overviewService.js +1 -0
  85. package/server/services/sqlite/structureService.js +66 -11
  86. package/server/services/sqlite/tableAdvisor.js +6 -0
  87. package/server/services/sqlite/tableDesigner/schemaMapping.js +7 -1
  88. package/server/services/sqlite/tableDesigner/sql.js +31 -2
  89. package/server/services/sqlite/tableDesigner/validation.js +69 -9
  90. package/server/services/sqlite/tableDesignerService.js +110 -5
  91. package/server/services/storage/appStateStore.js +590 -10
  92. package/server/utils/fileValidation.js +5 -0
  93. package/server/utils/userActionLog.js +49 -0
  94. package/frontend/assets/mockups/backups_2_create_backup_modal_1920.webp +0 -0
  95. package/frontend/assets/mockups/backups_3_edit_backup_modal_1920.webp +0 -0
  96. package/frontend/assets/mockups/backups_4_restore_backup_modal_1920.webp +0 -0
  97. package/frontend/assets/mockups/backups_5_delete_backup_modal_1920.webp +0 -0
  98. package/frontend/assets/mockups/data_3_data_export_modal_1920.webp +0 -0
@@ -23,42 +23,14 @@ function canOpenQueryHistoryInCharts(item) {
23
23
  return Boolean(item?.chartsEligible);
24
24
  }
25
25
 
26
- function getQueryTypeTone(queryType) {
27
- if (queryType === "select" || queryType === "update") {
28
- return "success";
29
- }
30
-
31
- if (queryType === "pragma") {
32
- return "primary";
33
- }
34
-
35
- return "muted";
36
- }
37
-
38
- function getExecutionSourceTone(source) {
39
- if (source === "api") {
40
- return "primary";
41
- }
42
-
43
- if (source === "cli") {
44
- return "warning";
45
- }
46
-
47
- if (source === "mcp") {
48
- return "muted";
49
- }
50
-
51
- return "success";
52
- }
53
-
54
26
  function renderRunItem(run) {
55
27
  const executedBy = String(run.executedBy ?? "user").trim().toLowerCase() || "user";
56
28
 
57
29
  return `
58
30
  <div class="border border-outline-variant/10 bg-surface-container px-3 py-3">
59
31
  <div class="flex flex-wrap items-center gap-2">
60
- ${renderStatusBadge(run.status, run.status === "error" ? "alert" : "success")}
61
- ${renderStatusBadge(executedBy, getExecutionSourceTone(executedBy))}
32
+ ${renderStatusBadge(run.status, "muted")}
33
+ ${renderStatusBadge(executedBy, "muted")}
62
34
  <span class="text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
63
35
  ${escapeHtml(formatCompactDateTime(run.executedAt))}
64
36
  </span>
@@ -100,9 +72,14 @@ export function renderQueryHistoryDetail({
100
72
  <section class="flex h-full min-h-0 flex-col bg-surface-low">
101
73
  <div class="border-b border-outline-variant/10 px-5 py-4">
102
74
  <div class="flex items-center justify-between gap-3">
103
- <span class="font-body text-sm font-black uppercase tracking-[0.18em] text-primary-container">
104
- Query Detail
105
- </span>
75
+ <div class="min-w-0">
76
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-primary-container/70">
77
+ SQL Editor // Query History
78
+ </div>
79
+ <h2 class="mt-1 truncate font-body text-lg font-black uppercase tracking-tight text-on-surface">
80
+ Loading
81
+ </h2>
82
+ </div>
106
83
  <button
107
84
  class="query-history-icon-button"
108
85
  data-action="clear-query-history-selection"
@@ -124,9 +101,14 @@ export function renderQueryHistoryDetail({
124
101
  <section class="flex h-full min-h-0 flex-col bg-surface-low">
125
102
  <div class="border-b border-outline-variant/10 px-5 py-4">
126
103
  <div class="flex items-center justify-between gap-3">
127
- <span class="font-body text-sm font-black uppercase tracking-[0.18em] text-primary-container">
128
- Query Detail
129
- </span>
104
+ <div class="min-w-0">
105
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-primary-container/70">
106
+ SQL Editor // Query History
107
+ </div>
108
+ <h2 class="mt-1 truncate font-body text-lg font-black uppercase tracking-tight text-on-surface">
109
+ Error
110
+ </h2>
111
+ </div>
130
112
  <button
131
113
  class="query-history-icon-button"
132
114
  data-action="clear-query-history-selection"
@@ -153,12 +135,10 @@ export function renderQueryHistoryDetail({
153
135
  ].join("")
154
136
  : "";
155
137
  const statusMarkup = [
156
- renderStatusBadge(item.queryType, getQueryTypeTone(item.queryType)),
157
- item.isSaved ? renderStatusBadge("saved", "primary") : "",
158
- item.isDestructive ? renderStatusBadge("destructive", "warning") : "",
159
- item.lastRun
160
- ? renderStatusBadge(item.lastRun.status, item.lastRun.status === "error" ? "alert" : "success")
161
- : "",
138
+ renderStatusBadge(item.queryType, "muted"),
139
+ item.isSaved ? renderStatusBadge("saved", "muted") : "",
140
+ item.isDestructive ? renderStatusBadge("destructive", "muted") : "",
141
+ item.lastRun ? renderStatusBadge(item.lastRun.status, "muted") : "",
162
142
  ].join("");
163
143
  const metaMarkup = [
164
144
  renderDetailMetaItem("Last Used", formatDateTime(item.lastUsedAt)),
@@ -176,9 +156,9 @@ export function renderQueryHistoryDetail({
176
156
  return [
177
157
  '<section class="flex h-full min-h-0 flex-col bg-surface-low">',
178
158
  '<div class="border-b border-outline-variant/10 px-5 py-4">',
179
- '<div class="flex items-center justify-between gap-3"><div>',
180
- '<div class="text-[10px] font-mono uppercase tracking-[0.18em] text-primary-container/70">Query Detail</div>',
181
- '<h2 class="mt-1 font-body text-lg font-black uppercase tracking-tight text-on-surface">',
159
+ '<div class="flex items-center justify-between gap-3"><div class="min-w-0">',
160
+ '<div class="font-mono text-[10px] uppercase tracking-[0.18em] text-primary-container/70">SQL Editor // Query History</div>',
161
+ '<h2 class="mt-1 truncate font-body text-lg font-black uppercase tracking-tight text-on-surface">',
182
162
  escapeHtml(item.displayTitle),
183
163
  "</h2></div>",
184
164
  '<button class="query-history-icon-button" data-action="clear-query-history-selection" type="button"><span class="material-symbols-outlined text-[18px]">close</span></button>',
@@ -215,7 +215,7 @@ function renderTextCharacterCountBadge(value) {
215
215
 
216
216
  return `
217
217
  <span
218
- class="border px-2 py-1 text-[9px] ${getFieldBadgeClassName("char-count")}"
218
+ class="status-badge status-badge--muted"
219
219
  data-row-editor-char-count
220
220
  ${count === null ? "hidden" : ""}
221
221
  >
@@ -337,22 +337,10 @@ function renderReadonlyField(field = {}, tableMeta = {}) {
337
337
  `;
338
338
  }
339
339
 
340
- function getValueStateBadgeClassName(state) {
341
- if (state === "null") {
342
- return "border-primary-container/35 bg-primary-container/15 text-primary-container";
343
- }
344
-
345
- if (state === "empty") {
346
- return "border-outline-variant/35 bg-surface-container-high text-on-surface-variant";
347
- }
348
-
349
- return "border-outline-variant/20 bg-surface-container text-on-surface-variant";
350
- }
351
-
352
340
  function renderValueStateBadge(state) {
353
341
  return `
354
342
  <span
355
- class="border px-2 py-1 text-[9px] ${getValueStateBadgeClassName(state)}"
343
+ class="status-badge status-badge--muted"
356
344
  data-row-editor-value-state
357
345
  data-value-state="${escapeHtml(state)}"
358
346
  >${escapeHtml(getRowEditorValueStateLabel(state))}</span>
@@ -447,44 +435,11 @@ function renderEditableField(field, tableMeta = {}) {
447
435
  `;
448
436
  }
449
437
 
450
- function getFieldBadgeClassName(tone) {
451
- if (tone === "primary-key") {
452
- return "border-primary-container/35 bg-primary-container/15 text-primary-container";
453
- }
454
-
455
- if (tone === "foreign-key") {
456
- return "border-tertiary-fixed-dim/35 bg-tertiary-fixed-dim/15 text-tertiary-fixed-dim";
457
- }
458
-
459
- if (tone === "url") {
460
- return "border-primary-container/35 bg-primary-container/15 text-primary-container";
461
- }
462
-
463
- if (tone === "email") {
464
- return "border-tertiary-fixed-dim/35 bg-tertiary-fixed-dim/15 text-tertiary-fixed-dim";
465
- }
466
-
467
- if (tone === "check") {
468
- return "border-tertiary-fixed-dim/35 bg-tertiary-fixed-dim/15 text-tertiary-fixed-dim";
469
- }
470
-
471
- if (tone === "filepath") {
472
- return "border-outline-variant/30 bg-surface-container-high text-on-surface";
473
- }
474
-
475
- if (tone === "char-count") {
476
- return "border-outline-variant/25 bg-surface-container text-on-surface-variant";
477
- }
478
-
479
- return "border-outline-variant/20 bg-surface-container text-on-surface-variant";
480
- }
481
-
482
438
  function renderFieldBadge(badge) {
483
439
  const label = typeof badge === "object" ? badge.label : badge;
484
- const tone = typeof badge === "object" ? badge.tone : "";
485
440
 
486
441
  return `
487
- <span class="border px-2 py-1 text-[9px] ${getFieldBadgeClassName(tone)}">
442
+ <span class="status-badge status-badge--muted">
488
443
  ${escapeHtml(label)}
489
444
  </span>
490
445
  `;
@@ -578,27 +533,19 @@ export function renderRowEditorPanel({
578
533
  ]
579
534
  .filter(Boolean)
580
535
  .join("");
536
+ const eyebrow = [sectionLabel, subtitle].filter(Boolean).join(" // ");
581
537
 
582
538
  return `
583
539
  <section class="flex h-full min-h-0 flex-col bg-surface-low">
584
540
  <header class="border-b border-outline-variant/10 bg-surface-container px-6 py-5">
585
541
  <div class="flex items-start justify-between gap-4">
586
542
  <div class="min-w-0 flex-1">
587
- <div class="text-[10px] font-bold uppercase tracking-[0.2em] text-primary-container">
588
- ${escapeHtml(sectionLabel)}
543
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-primary-container/70">
544
+ ${escapeHtml(eyebrow)}
589
545
  </div>
590
- <h2 class="mt-2 font-body text-3xl font-black uppercase tracking-tight text-primary-container">
546
+ <h2 class="mt-1 truncate font-body text-lg font-black uppercase tracking-tight text-on-surface">
591
547
  ${escapeHtml(title)}
592
548
  </h2>
593
- ${
594
- subtitle
595
- ? `
596
- <div class="mt-2 text-[10px] font-mono uppercase tracking-[0.16em] text-on-surface-variant/55">
597
- ${escapeHtml(subtitle)}
598
- </div>
599
- `
600
- : ""
601
- }
602
549
  </div>
603
550
  <button
604
551
  aria-label="Close panel"
@@ -1,4 +1,4 @@
1
- import { showToast } from '../store.js';
1
+ import { clearStructureSelection, showToast } from '../store.js';
2
2
  import { replaceChildrenFromRenderedMarkup } from '../utils/dom.js';
3
3
  import { escapeHtml, formatNumber } from '../utils/format.js';
4
4
 
@@ -56,10 +56,68 @@ function getTableId(tableName) {
56
56
  return `table:${tableName}`;
57
57
  }
58
58
 
59
+ function isShadowRelationship(edgeData) {
60
+ return edgeData?.relationshipKind === 'shadow';
61
+ }
62
+
63
+ function resolveShadowOwnerTableName(shadowTable, tables, tableMap) {
64
+ if (!shadowTable?.isShadow) {
65
+ return '';
66
+ }
67
+
68
+ const explicitOwnerName = String(shadowTable.shadowOwnerTable ?? '');
69
+ const explicitOwner = tableMap.get(explicitOwnerName);
70
+
71
+ if (explicitOwner?.isVirtual) {
72
+ return explicitOwnerName;
73
+ }
74
+
75
+ return (
76
+ tables
77
+ .filter(table => table.isVirtual && shadowTable.name.startsWith(`${table.name}_`))
78
+ .sort((left, right) => right.name.length - left.name.length)[0]?.name ?? ''
79
+ );
80
+ }
81
+
82
+ function unquoteSqlIdentifier(identifier) {
83
+ const value = String(identifier ?? '').trim();
84
+
85
+ if (value.length >= 2 && value[0] === '"' && value.at(-1) === '"') {
86
+ return value.slice(1, -1).replaceAll('""', '"');
87
+ }
88
+
89
+ if (value.length >= 2 && value[0] === '`' && value.at(-1) === '`') {
90
+ return value.slice(1, -1).replaceAll('``', '`');
91
+ }
92
+
93
+ if (value.length >= 2 && value[0] === '[' && value.at(-1) === ']') {
94
+ return value.slice(1, -1);
95
+ }
96
+
97
+ return value;
98
+ }
99
+
100
+ function extractVirtualTableModule(ddl = '') {
101
+ const match = String(ddl ?? '').match(
102
+ /\bUSING\s+("[^"]+(?:""[^"]*)*"|`[^`]+(?:``[^`]*)*`|\[[^\]]+\]|[A-Za-z_][A-Za-z0-9_]*)/i,
103
+ );
104
+
105
+ return match ? unquoteSqlIdentifier(match[1]) : '';
106
+ }
107
+
108
+ function resolveVirtualTableModule(table) {
109
+ if (!table?.isVirtual) {
110
+ return '';
111
+ }
112
+
113
+ return String(table.virtualModule ?? '').trim() || extractVirtualTableModule(table.ddl);
114
+ }
115
+
59
116
  function getSchemaSignature(schema) {
60
117
  return JSON.stringify(
61
118
  (schema?.tables ?? []).map(table => ({
62
119
  name: table.name,
120
+ virtualModule: table.virtualModule ?? null,
63
121
  columns: (table.columns ?? []).map(column => column.name),
64
122
  foreignKeys: (table.foreignKeys ?? []).map(foreignKey => ({
65
123
  referencedTable: foreignKey.referencedTable,
@@ -237,6 +295,18 @@ function createColumnFlags(column, foreignKeyColumns) {
237
295
  return flags.join('');
238
296
  }
239
297
 
298
+ function renderTableKindFlag(tableData) {
299
+ if (tableData?.isVirtual) {
300
+ return '<span class="structure-graph__flag is-virtual" title="Virtual table">Virtual</span>';
301
+ }
302
+
303
+ if (tableData?.isShadow) {
304
+ return '<span class="structure-graph__flag is-shadow" title="Shadow table">Shadow</span>';
305
+ }
306
+
307
+ return '';
308
+ }
309
+
240
310
  function quoteSqlIdentifier(identifier) {
241
311
  return `"${String(identifier ?? '').replaceAll('"', '""')}"`;
242
312
  }
@@ -298,6 +368,47 @@ function buildJoinSql(edgeData) {
298
368
  }
299
369
 
300
370
  function renderJoinInspector(edgeData) {
371
+ if (isShadowRelationship(edgeData)) {
372
+ const sourceTable = edgeData?.sourceTable || '';
373
+ const targetTable = edgeData?.targetTable || '';
374
+
375
+ return `
376
+ <div class="structure-graph__panel">
377
+ <div class="space-y-3">
378
+ <div class="structure-graph__panel-heading">
379
+ <div class="structure-graph__eyebrow">Shadow Link</div>
380
+ <button
381
+ class="query-history-icon-button"
382
+ data-structure-graph-action="toggle-inspector"
383
+ type="button"
384
+ aria-label="Hide inspector"
385
+ >
386
+ <span class="material-symbols-outlined text-[18px]">close</span>
387
+ </button>
388
+ </div>
389
+ <div class="structure-graph__title">${escapeHtml(sourceTable)} → ${escapeHtml(targetTable)}</div>
390
+ <div class="structure-graph__subtitle">Shadow table relationship</div>
391
+ </div>
392
+
393
+ <section class="structure-graph__section">
394
+ <div class="structure-graph__section-title">Relationship</div>
395
+ <div class="structure-graph__join-flow">
396
+ <div class="structure-graph__join-table">${escapeHtml(sourceTable)}</div>
397
+ <span class="material-symbols-outlined structure-graph__join-arrow" aria-hidden="true">arrow_forward</span>
398
+ <div class="structure-graph__join-table">${escapeHtml(targetTable)}</div>
399
+ </div>
400
+ <div class="structure-graph__join-condition-list">
401
+ <div class="structure-graph__join-condition">
402
+ <span>Virtual table</span>
403
+ <span aria-hidden="true">→</span>
404
+ <span>Shadow table</span>
405
+ </div>
406
+ </div>
407
+ </section>
408
+ </div>
409
+ `;
410
+ }
411
+
301
412
  const sourceTable = edgeData?.sourceTable || '';
302
413
  const targetTable = edgeData?.targetTable || '';
303
414
  const conditions = getJoinConditions(edgeData);
@@ -429,7 +540,10 @@ export function renderInspector(tableData) {
429
540
  <span class="material-symbols-outlined text-[18px]">close</span>
430
541
  </button>
431
542
  </div>
432
- <div class="structure-graph__title">${escapeHtml(tableData.name)}</div>
543
+ <div class="flex min-w-0 flex-wrap items-center gap-2">
544
+ <div class="structure-graph__title min-w-0">${escapeHtml(tableData.name)}</div>
545
+ ${renderTableKindFlag(tableData)}
546
+ </div>
433
547
  </div>
434
548
 
435
549
  <div class="structure-graph__summary">
@@ -474,19 +588,51 @@ export function renderInspector(tableData) {
474
588
  export function buildGraphElements(schema) {
475
589
  const tables = schema?.tables ?? [];
476
590
  const tableMap = new Map(tables.map(table => [table.name, table]));
477
- const nodes = tables.map(table => ({
478
- group: 'nodes',
479
- data: {
480
- id: getTableId(table.name),
481
- label: table.name,
482
- tableName: table.name,
483
- width: Math.max(184, table.name.length * 9 + 64),
484
- height: 56,
485
- table,
486
- },
487
- }));
591
+ const nodes = tables.map(table => {
592
+ const isVirtual = Boolean(table.isVirtual);
593
+ const isShadow = Boolean(table.isShadow);
594
+ const virtualModule = resolveVirtualTableModule(table);
595
+ const moduleWidth = virtualModule ? virtualModule.length * 8 + 72 : 0;
596
+
597
+ return {
598
+ group: 'nodes',
599
+ classes: [isVirtual ? 'virtual-table' : '', isShadow ? 'shadow-table' : ''].filter(Boolean).join(' '),
600
+ data: {
601
+ id: getTableId(table.name),
602
+ label: table.name,
603
+ tableName: table.name,
604
+ tableKind: table.tableKind ?? 'table',
605
+ isVirtual,
606
+ isShadow,
607
+ virtualModule,
608
+ width: Math.max(isVirtual ? 208 : 184, table.name.length * 9 + 64, moduleWidth),
609
+ height: isVirtual ? (virtualModule ? 82 : 70) : 56,
610
+ table,
611
+ },
612
+ };
613
+ });
488
614
  const edges = [];
489
615
 
616
+ tables.forEach(table => {
617
+ const shadowOwnerTableName = resolveShadowOwnerTableName(table, tables, tableMap);
618
+
619
+ if (!shadowOwnerTableName || !tableMap.has(shadowOwnerTableName)) {
620
+ return;
621
+ }
622
+
623
+ edges.push({
624
+ group: 'edges',
625
+ classes: 'shadow-link',
626
+ data: {
627
+ source: getTableId(shadowOwnerTableName),
628
+ target: getTableId(table.name),
629
+ sourceTable: shadowOwnerTableName,
630
+ targetTable: table.name,
631
+ relationshipKind: 'shadow',
632
+ },
633
+ });
634
+ });
635
+
490
636
  tables.forEach(table => {
491
637
  (table.foreignKeys ?? []).forEach((foreignKey, foreignKeyIndex) => {
492
638
  if (!tableMap.has(foreignKey.referencedTable)) {
@@ -582,6 +728,27 @@ export function createCytoscapeInstance(container, elements) {
582
728
  'transition-duration': '140ms',
583
729
  },
584
730
  },
731
+ {
732
+ selector: 'node.virtual-table',
733
+ style: {
734
+ 'background-color': '#3d1238',
735
+ 'border-color': '#ff4fa3',
736
+ 'border-width': 2.4,
737
+ color: '#ffe8f4',
738
+ 'font-size': 12.5,
739
+ 'text-wrap': 'wrap',
740
+ 'text-max-width': 174,
741
+ },
742
+ },
743
+ {
744
+ selector: 'node.shadow-table',
745
+ style: {
746
+ 'background-color': '#221f1a',
747
+ 'border-color': '#8f8a75',
748
+ 'border-style': 'dashed',
749
+ color: '#f3ecd0',
750
+ },
751
+ },
585
752
  {
586
753
  selector: 'node.selected',
587
754
  style: {
@@ -643,6 +810,22 @@ export function createCytoscapeInstance(container, elements) {
643
810
  'transition-duration': '120ms',
644
811
  },
645
812
  },
813
+ {
814
+ selector: 'edge.shadow-link',
815
+ style: {
816
+ width: 1.8,
817
+ label: '',
818
+ 'line-color': '#8f8a75',
819
+ 'line-style': 'dashed',
820
+ 'target-arrow-color': '#8f8a75',
821
+ 'target-arrow-shape': 'vee',
822
+ 'arrow-scale': 0.9,
823
+ 'text-background-opacity': 0,
824
+ 'text-border-opacity': 0,
825
+ 'text-outline-width': 0,
826
+ 'z-index': 0,
827
+ },
828
+ },
646
829
  {
647
830
  selector: 'edge.related',
648
831
  style: {
@@ -714,6 +897,21 @@ function showEdgeReadout(edge, renderedPosition) {
714
897
  return;
715
898
  }
716
899
 
900
+ if (isShadowRelationship(edge.data())) {
901
+ const sourceTable = edge.data('sourceTable') || edge.source().data('tableName') || '?';
902
+ const targetTable = edge.data('targetTable') || edge.target().data('tableName') || '?';
903
+
904
+ currentGraph.edgeReadout.innerHTML = `
905
+ <div class="structure-graph__edge-readout-meta">Shadow table</div>
906
+ <div class="structure-graph__edge-readout-path">
907
+ ${escapeHtml(sourceTable)} <span aria-hidden="true">→</span> ${escapeHtml(targetTable)}
908
+ </div>
909
+ `;
910
+ currentGraph.edgeReadout.removeAttribute('hidden');
911
+ positionEdgeReadout(renderedPosition);
912
+ return;
913
+ }
914
+
717
915
  const sourceTable = edge.data('sourceTable') || edge.source().data('tableName') || '?';
718
916
  const targetTable = edge.data('targetTable') || edge.target().data('tableName') || '?';
719
917
  const sourceColumn = edge.data('sourceColumn') || '?';
@@ -741,6 +939,65 @@ function hideEdgeReadout() {
741
939
  currentGraph.edgeReadout.setAttribute('hidden', 'hidden');
742
940
  }
743
941
 
942
+ function clearVirtualModuleBadges(graph = currentGraph) {
943
+ if (!graph?.virtualModuleBadges) {
944
+ return;
945
+ }
946
+
947
+ graph.virtualModuleBadges.forEach(badge => badge.remove());
948
+ graph.virtualModuleBadges.clear();
949
+ }
950
+
951
+ function syncVirtualModuleBadges(graph = currentGraph) {
952
+ if (!graph?.cy || !graph.canvasShell || !graph.virtualModuleBadges) {
953
+ return;
954
+ }
955
+
956
+ const activeNodeIds = new Set();
957
+
958
+ graph.cy.nodes('.virtual-table').forEach(node => {
959
+ const moduleName = String(node.data('virtualModule') ?? '').trim();
960
+
961
+ if (!moduleName) {
962
+ return;
963
+ }
964
+
965
+ const nodeId = node.id();
966
+ activeNodeIds.add(nodeId);
967
+
968
+ let badge = graph.virtualModuleBadges.get(nodeId);
969
+
970
+ if (!badge) {
971
+ badge = document.createElement('div');
972
+ badge.className = 'structure-graph__virtual-module-badge';
973
+ badge.dataset.structureVirtualModuleBadge = node.data('tableName') || '';
974
+ graph.canvasShell.appendChild(badge);
975
+ graph.virtualModuleBadges.set(nodeId, badge);
976
+ }
977
+
978
+ badge.textContent = moduleName;
979
+ badge.classList.toggle('is-dimmed', node.hasClass('dimmed'));
980
+ badge.classList.toggle('is-related', node.hasClass('related'));
981
+ badge.classList.toggle('is-selected', node.hasClass('selected'));
982
+
983
+ const position = node.renderedPosition();
984
+ const renderedHeight = Number(node.renderedHeight?.() ?? 0);
985
+ const yOffset = Math.max(12, Math.min(22, renderedHeight * 0.24));
986
+ const scale = Math.max(0.72, Math.min(1, graph.cy.zoom()));
987
+
988
+ badge.style.left = `${position.x}px`;
989
+ badge.style.top = `${position.y + yOffset}px`;
990
+ badge.style.transform = `translate(-50%, -50%) scale(${scale})`;
991
+ });
992
+
993
+ graph.virtualModuleBadges.forEach((badge, nodeId) => {
994
+ if (!activeNodeIds.has(nodeId)) {
995
+ badge.remove();
996
+ graph.virtualModuleBadges.delete(nodeId);
997
+ }
998
+ });
999
+ }
1000
+
744
1001
  function createGraphStateSnapshot(graph = currentGraph) {
745
1002
  if (!graph?.cy) {
746
1003
  return null;
@@ -858,6 +1115,8 @@ function destroyCurrentGraph() {
858
1115
  currentGraph.resizeObserver.disconnect();
859
1116
  }
860
1117
 
1118
+ clearVirtualModuleBadges(currentGraph);
1119
+
861
1120
  if (currentGraph.resizeHandler) {
862
1121
  window.removeEventListener('resize', currentGraph.resizeHandler);
863
1122
  }
@@ -987,6 +1246,8 @@ function clearSelection() {
987
1246
  syncEntryHighlights();
988
1247
  replaceChildrenFromRenderedMarkup(currentGraph.inspector, getDefaultInspectorMarkup());
989
1248
  updateOpenDataButton();
1249
+ syncVirtualModuleBadges();
1250
+ clearStructureSelection();
990
1251
  }
991
1252
 
992
1253
  function applyTableSelection(node, { focus = true } = {}) {
@@ -1002,6 +1263,7 @@ function applyTableSelection(node, { focus = true } = {}) {
1002
1263
  highlightConnectedElements(currentGraph.cy, node);
1003
1264
  syncEntryHighlights([tableData.name]);
1004
1265
  updateOpenDataButton();
1266
+ syncVirtualModuleBadges();
1005
1267
 
1006
1268
  if (focus) {
1007
1269
  currentGraph.cy.animate(
@@ -1033,6 +1295,7 @@ function applyEdgeSelection(edge) {
1033
1295
  highlightConnectedElements(currentGraph.cy, edge);
1034
1296
  syncEntryHighlights([], [edge.data('sourceTable'), edge.data('targetTable')]);
1035
1297
  updateOpenDataButton();
1298
+ syncVirtualModuleBadges();
1036
1299
  return edge;
1037
1300
  }
1038
1301
 
@@ -1202,6 +1465,7 @@ function runLayout(cy, onStop, { randomize = false } = {}) {
1202
1465
 
1203
1466
  applyReadableLayoutPositions(cy, { variant: currentGraph?.layoutVariant ?? 0 });
1204
1467
  cy.fit(cy.elements(), 90);
1468
+ syncVirtualModuleBadges();
1205
1469
 
1206
1470
  if (typeof onStop === 'function') {
1207
1471
  onStop();
@@ -1235,6 +1499,8 @@ function restorePersistedViewport(cy, persistedState) {
1235
1499
  cy.pan(persistedState.pan);
1236
1500
  }
1237
1501
 
1502
+ syncVirtualModuleBadges();
1503
+
1238
1504
  return true;
1239
1505
  }
1240
1506
 
@@ -1375,6 +1641,7 @@ export async function mountStructureGraph(snapshot) {
1375
1641
  persistStateOnDestroy: true,
1376
1642
  selectedEdgeId: null,
1377
1643
  selectedTableName: null,
1644
+ virtualModuleBadges: new Map(),
1378
1645
  };
1379
1646
 
1380
1647
  currentGraph.cleanup.push(setupToolbar(cy));
@@ -1407,14 +1674,20 @@ export async function mountStructureGraph(snapshot) {
1407
1674
  });
1408
1675
 
1409
1676
  cy.on('dragfree', 'node', () => {
1677
+ syncVirtualModuleBadges();
1410
1678
  scheduleGraphStatePersist(0);
1411
1679
  });
1412
1680
 
1681
+ cy.on('position drag', 'node', () => {
1682
+ syncVirtualModuleBadges();
1683
+ });
1684
+
1413
1685
  cy.on('mouseover', 'edge', event => {
1414
1686
  const edge = event.target;
1415
1687
  highlightConnectedElements(cy, edge);
1416
1688
  syncEntryHighlights([], [edge.data('sourceTable'), edge.data('targetTable')]);
1417
1689
  showEdgeReadout(edge, event.renderedPosition);
1690
+ syncVirtualModuleBadges();
1418
1691
  });
1419
1692
 
1420
1693
  cy.on('mousemove', 'edge', event => {
@@ -1424,10 +1697,12 @@ export async function mountStructureGraph(snapshot) {
1424
1697
  cy.on('mouseout', 'edge', () => {
1425
1698
  hideEdgeReadout();
1426
1699
  restoreGraphState();
1700
+ syncVirtualModuleBadges();
1427
1701
  });
1428
1702
 
1429
1703
  cy.on('pan zoom', () => {
1430
1704
  hideEdgeReadout();
1705
+ syncVirtualModuleBadges();
1431
1706
  scheduleGraphStatePersist(300);
1432
1707
  });
1433
1708
 
@@ -1465,14 +1740,18 @@ export async function mountStructureGraph(snapshot) {
1465
1740
  });
1466
1741
  }
1467
1742
 
1743
+ syncVirtualModuleBadges();
1744
+
1468
1745
  if (typeof ResizeObserver === 'function') {
1469
1746
  currentGraph.resizeObserver = new ResizeObserver(() => {
1470
1747
  cy.resize();
1748
+ syncVirtualModuleBadges();
1471
1749
  });
1472
1750
  currentGraph.resizeObserver.observe(canvas);
1473
1751
  } else {
1474
1752
  currentGraph.resizeHandler = () => {
1475
1753
  cy.resize();
1754
+ syncVirtualModuleBadges();
1476
1755
  };
1477
1756
  window.addEventListener('resize', currentGraph.resizeHandler);
1478
1757
  }