sqlite-hub 1.2.0 → 1.5.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 (169) hide show
  1. package/README.md +113 -71
  2. package/bin/sqlite-hub.js +723 -116
  3. package/docs/API.md +152 -0
  4. package/docs/CLI.md +249 -0
  5. package/docs/CLI_API_PARITY.md +61 -0
  6. package/docs/changelog.md +149 -0
  7. package/docs/guidelines/AGENTS.md +32 -0
  8. package/docs/guidelines/DESIGN.md +55 -0
  9. package/docs/shortkeys.md +27 -0
  10. package/docs/todo.md +4 -0
  11. package/examples/api/generate-types.js +52 -0
  12. package/frontend/assets/mockups/backups_1_1920.webp +0 -0
  13. package/frontend/assets/mockups/backups_2_create_backup_modal_1920.webp +0 -0
  14. package/frontend/assets/mockups/backups_3_edit_backup_modal_1920.webp +0 -0
  15. package/frontend/assets/mockups/backups_4_restore_backup_modal_1920.webp +0 -0
  16. package/frontend/assets/mockups/backups_5_delete_backup_modal_1920.webp +0 -0
  17. package/frontend/assets/mockups/charts_1_1920.webp +0 -0
  18. package/frontend/assets/mockups/charts_2_query_detail_1920.webp +0 -0
  19. package/frontend/assets/mockups/charts_3_create_query_chart_modal_1920.webp +0 -0
  20. package/frontend/assets/mockups/charts_4_edit_query_chart_modal_1920.webp +0 -0
  21. package/frontend/assets/mockups/charts_5_delete_query_chart_modal_1920.webp +0 -0
  22. package/frontend/assets/mockups/charts_6_copy_column_modal_1920.webp +0 -0
  23. package/frontend/assets/mockups/connections_1_1920.webp +0 -0
  24. package/frontend/assets/mockups/connections_2_create_connection_modal_1920.webp +0 -0
  25. package/frontend/assets/mockups/connections_3_open_connection_modal_1920.webp +0 -0
  26. package/frontend/assets/mockups/data_1_1920.webp +0 -0
  27. package/frontend/assets/mockups/data_2_roweditor_1920.webp +0 -0
  28. package/frontend/assets/mockups/data_3_data_export_modal_1920.webp +0 -0
  29. package/frontend/assets/mockups/documents_1_1920.webp +0 -0
  30. package/frontend/assets/mockups/documents_2_document_insert_table_modal_1920.webp +0 -0
  31. package/frontend/assets/mockups/documents_3_document_insert_note_modal_1920.webp +0 -0
  32. package/frontend/assets/mockups/media_tagging_queue_1_1920.webp +0 -0
  33. package/frontend/assets/mockups/media_tagging_setup_1_1920.webp +0 -0
  34. package/frontend/assets/mockups/media_tagging_setup_2_create_media_tagging_tag_table_modal_1920.webp +0 -0
  35. package/frontend/assets/mockups/media_tagging_setup_3_create_media_tagging_mapping_table_modal_1920.webp +0 -0
  36. package/frontend/assets/mockups/overview_1_1920.webp +0 -0
  37. package/frontend/assets/mockups/settings_1_1920.webp +0 -0
  38. package/frontend/assets/mockups/sql_editor_1_1920.webp +0 -0
  39. package/frontend/assets/mockups/sql_editor_2_query_detail_1920.webp +0 -0
  40. package/frontend/assets/mockups/sql_editor_3_query_export_modal_1920.webp +0 -0
  41. package/frontend/assets/mockups/structure_1_1920.webp +0 -0
  42. package/frontend/assets/mockups/structure_2_generate_types_modal_1920.webp +0 -0
  43. package/frontend/assets/mockups/structure_3_generate_types_modal_1920.webp +0 -0
  44. package/frontend/assets/mockups/table_designer_1_1920.webp +0 -0
  45. package/frontend/assets/mockups/table_designer_2_table_designer_constraints_modal_1920.webp +0 -0
  46. package/frontend/js/api.js +63 -0
  47. package/frontend/js/app.js +310 -10
  48. package/frontend/js/components/connectionCard.js +4 -2
  49. package/frontend/js/components/emptyState.js +4 -4
  50. package/frontend/js/components/generateTypesDropdown.js +33 -0
  51. package/frontend/js/components/metricCard.js +1 -1
  52. package/frontend/js/components/modal.js +555 -21
  53. package/frontend/js/components/pageHeader.js +1 -1
  54. package/frontend/js/components/queryHistoryDetail.js +3 -3
  55. package/frontend/js/components/queryHistoryHeader.js +1 -1
  56. package/frontend/js/components/queryHistoryPanel.js +1 -1
  57. package/frontend/js/components/rowEditorPanel.js +13 -3
  58. package/frontend/js/components/sidebar.js +41 -7
  59. package/frontend/js/components/topNav.js +2 -14
  60. package/frontend/js/router.js +38 -6
  61. package/frontend/js/store.js +870 -8
  62. package/frontend/js/utils/inputClear.js +36 -0
  63. package/frontend/js/utils/syntheticData.js +240 -0
  64. package/frontend/js/views/backups.js +581 -37
  65. package/frontend/js/views/charts.js +9 -9
  66. package/frontend/js/views/connections.js +2 -2
  67. package/frontend/js/views/data.js +17 -1
  68. package/frontend/js/views/documents.js +3 -3
  69. package/frontend/js/views/editor.js +6 -6
  70. package/frontend/js/views/logs.js +339 -0
  71. package/frontend/js/views/mediaTagging.js +7 -5
  72. package/frontend/js/views/overview.js +3 -3
  73. package/frontend/js/views/settings.js +80 -30
  74. package/frontend/js/views/structure.js +12 -1
  75. package/frontend/js/views/tableAdvisor.js +385 -0
  76. package/frontend/js/views/tableDesigner.js +6 -0
  77. package/frontend/styles/base.css +1 -40
  78. package/frontend/styles/components.css +258 -232
  79. package/frontend/styles/structure-graph.css +32 -68
  80. package/frontend/styles/tailwind.css +8 -4
  81. package/frontend/styles/tailwind.generated.css +1 -1
  82. package/frontend/styles/tokens.css +28 -21
  83. package/frontend/styles/views.css +102 -261
  84. package/package.json +18 -2
  85. package/server/routes/backups.js +18 -2
  86. package/server/routes/data.js +45 -0
  87. package/server/routes/externalApi.js +321 -2
  88. package/server/routes/logs.js +127 -0
  89. package/server/routes/structure.js +22 -0
  90. package/server/server.js +3 -0
  91. package/server/services/databaseCommandService.js +59 -0
  92. package/server/services/sqlite/backupDiff.js +914 -0
  93. package/server/services/sqlite/backupService.js +77 -1
  94. package/server/services/sqlite/dataBrowserService.js +36 -0
  95. package/server/services/sqlite/introspection.js +226 -22
  96. package/server/services/sqlite/structureService.js +7 -0
  97. package/server/services/sqlite/syntheticDataGenerator.js +728 -0
  98. package/server/services/sqlite/tableAdvisor.js +790 -0
  99. package/server/services/storage/appStateStore.js +773 -169
  100. package/server/services/typeGenerationService.js +663 -0
  101. package/tailwind.config.cjs +0 -1
  102. package/.github/funding.yml +0 -2
  103. package/.github/workflows/ci.yml +0 -36
  104. package/database.sqlite +0 -0
  105. package/frontend/assets/mockups/charts_1_bars_1200.webp +0 -0
  106. package/frontend/assets/mockups/charts_2_pie_1200.webp +0 -0
  107. package/frontend/assets/mockups/charts_3_scatter_plot_1200.webp +0 -0
  108. package/frontend/assets/mockups/connections_1200.webp +0 -0
  109. package/frontend/assets/mockups/data_1_1200.webp +0 -0
  110. package/frontend/assets/mockups/data_2_row_editor_1200.webp +0 -0
  111. package/frontend/assets/mockups/documents_1200.webp +0 -0
  112. package/frontend/assets/mockups/media_tagging_1_setup_1200.webp +0 -0
  113. package/frontend/assets/mockups/media_tagging_2_tagging_queue_1200.webp +0 -0
  114. package/frontend/assets/mockups/media_tagging_3_media_viewer_1200.webp +0 -0
  115. package/frontend/assets/mockups/overview_1200.webp +0 -0
  116. package/frontend/assets/mockups/settings_1200.webp +0 -0
  117. package/frontend/assets/mockups/sql_editor_1_1200.webp +0 -0
  118. package/frontend/assets/mockups/sql_editor_2_query_details_1200.webp +0 -0
  119. package/frontend/assets/mockups/sql_editor_3_export_column_1200.webp +0 -0
  120. package/frontend/assets/mockups/sql_editor_4_export_column_as_markdown_1200.webp +0 -0
  121. package/frontend/assets/mockups/sql_editor_5_export_query_result_1200.webp +0 -0
  122. package/frontend/assets/mockups/structure_1_1200.webp +0 -0
  123. package/frontend/assets/mockups/structure_2_inspector_1200.webp +0 -0
  124. package/frontend/assets/mockups/table_designer_1_1200.webp +0 -0
  125. package/frontend/assets/mockups/table_designer_2_checks_1200.webp +0 -0
  126. package/tests/api-token-auth.test.js +0 -279
  127. package/tests/backup-manager.test.js +0 -140
  128. package/tests/backups-view.test.js +0 -70
  129. package/tests/charts-height-preset-storage.test.js +0 -60
  130. package/tests/charts-route-state.test.js +0 -144
  131. package/tests/check-constraint-options.test.js +0 -90
  132. package/tests/cli-args.test.js +0 -113
  133. package/tests/cli-service-delegation.test.js +0 -140
  134. package/tests/connection-removal.test.js +0 -52
  135. package/tests/connections-file-dialog-route.test.js +0 -89
  136. package/tests/copy-column-modal.test.js +0 -131
  137. package/tests/database-command-service.test.js +0 -174
  138. package/tests/database-documents.test.js +0 -85
  139. package/tests/documents-view.test.js +0 -132
  140. package/tests/dropdown-button.test.js +0 -101
  141. package/tests/email-preview.test.js +0 -89
  142. package/tests/export-blob.test.js +0 -99
  143. package/tests/export-filenames.test.js +0 -38
  144. package/tests/file-path-preview.test.js +0 -165
  145. package/tests/form-controls.test.js +0 -34
  146. package/tests/json-preview.test.js +0 -49
  147. package/tests/local-request-security.test.js +0 -85
  148. package/tests/markdown-documents.test.js +0 -103
  149. package/tests/native-file-dialog.test.js +0 -105
  150. package/tests/query-editor.test.js +0 -28
  151. package/tests/query-history-detail.test.js +0 -37
  152. package/tests/query-history-header.test.js +0 -30
  153. package/tests/query-results-truncation.test.js +0 -20
  154. package/tests/risky-sql.test.js +0 -30
  155. package/tests/row-editor-json.test.js +0 -82
  156. package/tests/row-editor-null-values.test.js +0 -184
  157. package/tests/row-editor-timestamp-preview.test.js +0 -192
  158. package/tests/security-paths.test.js +0 -84
  159. package/tests/settings-api-tokens-route.test.js +0 -97
  160. package/tests/settings-metadata.test.js +0 -114
  161. package/tests/settings-view.test.js +0 -108
  162. package/tests/sql-formatter.test.js +0 -173
  163. package/tests/sql-highlight.test.js +0 -38
  164. package/tests/sql-identifier-safety.test.js +0 -171
  165. package/tests/sql-result-limit.test.js +0 -66
  166. package/tests/structure-view.test.js +0 -60
  167. package/tests/table-designer-v2-unique-constraints.test.js +0 -78
  168. package/tests/table-scroll-state.test.js +0 -70
  169. package/tests/text-cell-stats.test.js +0 -38
@@ -1,6 +1,14 @@
1
1
  import { renderStatusBadge } from '../components/badges.js';
2
2
  import { renderPageHeader } from '../components/pageHeader.js';
3
- import { escapeHtml, formatBytes, formatCompactDateTime, truncateMiddle } from '../utils/format.js';
3
+ import {
4
+ escapeHtml,
5
+ formatBytes,
6
+ formatCellValue,
7
+ formatCompactDateTime,
8
+ formatDateTime,
9
+ formatNumber,
10
+ truncateMiddle,
11
+ } from '../utils/format.js';
4
12
 
5
13
  const STATUS_TONE = {
6
14
  creating: 'muted',
@@ -28,20 +36,34 @@ function isBackupBusy(backup, state) {
28
36
  return Boolean(state.backups.operationLoading) || ['creating', 'verifying', 'restoring'].includes(backup.status);
29
37
  }
30
38
 
39
+ function isBackupForActiveConnection(backup, state) {
40
+ const activeConnection = state.connections.active;
41
+
42
+ if (!activeConnection) {
43
+ return false;
44
+ }
45
+
46
+ if (backup.connectionId && String(backup.connectionId) === String(activeConnection.id)) {
47
+ return true;
48
+ }
49
+
50
+ return Boolean(backup.sourcePath && activeConnection.path && backup.sourcePath === activeConnection.path);
51
+ }
52
+
31
53
  function renderBackupMetadataItem(label, value, extraClass = '') {
32
54
  return `
33
- <div class="border border-outline-variant/10 bg-surface-container-lowest px-3 py-2 ${extraClass}">
55
+ <div class="min-w-0 border border-outline-variant/10 bg-surface-container-lowest px-3 py-2 ${extraClass}">
34
56
  <div class="font-mono text-[9px] uppercase tracking-[0.16em] text-on-surface-variant/45">${escapeHtml(label)}</div>
35
- <div class="mt-1 font-mono text-[10px] uppercase tracking-[0.1em] text-on-surface/80">${escapeHtml(value)}</div>
57
+ <div class="mt-1 break-words font-mono text-[10px] uppercase tracking-[0.1em] text-on-surface/80">${escapeHtml(value)}</div>
36
58
  </div>
37
59
  `;
38
60
  }
39
61
 
40
62
  function renderBackupMetadata(backup) {
41
63
  return `
42
- <div class="grid min-w-[24rem] grid-cols-2 gap-2">
64
+ <div class="grid min-w-0 grid-cols-2 gap-2">
43
65
  ${renderBackupMetadataItem('Size', formatBytes(backup.sizeBytes))}
44
- <div class="border border-outline-variant/10 bg-surface-container-lowest px-3 py-2">
66
+ <div class="min-w-0 border border-outline-variant/10 bg-surface-container-lowest px-3 py-2">
45
67
  <div class="font-mono text-[9px] uppercase tracking-[0.16em] text-on-surface-variant/45">Status</div>
46
68
  <div class="mt-1">${renderBackupStatus(backup)}</div>
47
69
  </div>
@@ -51,45 +73,106 @@ function renderBackupMetadata(backup) {
51
73
  `;
52
74
  }
53
75
 
76
+ function getBackupsUsageSummary(items = []) {
77
+ return items.reduce(
78
+ (summary, backup) => {
79
+ const sizeBytes = Number(backup.sizeBytes ?? 0);
80
+ const fileExists = backup.fileExists !== false;
81
+
82
+ summary.backupCount += 1;
83
+
84
+ if (fileExists) {
85
+ summary.availableCount += 1;
86
+ summary.totalSizeBytes += Number.isFinite(sizeBytes) && sizeBytes > 0 ? sizeBytes : 0;
87
+ } else {
88
+ summary.missingCount += 1;
89
+ }
90
+
91
+ return summary;
92
+ },
93
+ {
94
+ totalSizeBytes: 0,
95
+ backupCount: 0,
96
+ availableCount: 0,
97
+ missingCount: 0,
98
+ },
99
+ );
100
+ }
101
+
102
+ function renderBackupUsageSummary(state) {
103
+ const summary = getBackupsUsageSummary(state.backups.items);
104
+ const details = [
105
+ `${formatNumber(summary.availableCount)} available`,
106
+ summary.missingCount ? `${formatNumber(summary.missingCount)} missing` : '',
107
+ ]
108
+ .filter(Boolean)
109
+ .join(' // ');
110
+
111
+ return `
112
+ <div class="mb-4 grid gap-3 border border-outline-variant/10 bg-surface-container-low px-4 py-4 md:grid-cols-[minmax(14rem,1fr)_minmax(10rem,0.45fr)]">
113
+ <div class="min-w-0">
114
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/55">Total backup usage</div>
115
+ <div class="mt-1 font-body text-2xl font-black uppercase text-primary-container">${escapeHtml(formatBytes(summary.totalSizeBytes))}</div>
116
+ </div>
117
+ <div class="min-w-0 border border-outline-variant/10 bg-surface-container-lowest px-3 py-2">
118
+ <div class="font-mono text-[9px] uppercase tracking-[0.16em] text-on-surface-variant/45">Backups</div>
119
+ <div class="mt-1 font-mono text-[10px] uppercase tracking-[0.1em] text-on-surface/80">
120
+ ${escapeHtml(formatNumber(summary.backupCount))}${details ? ` // ${escapeHtml(details)}` : ''}
121
+ </div>
122
+ </div>
123
+ </div>
124
+ `;
125
+ }
126
+
54
127
  function renderBackupRows(state) {
55
128
  return state.backups.items
56
129
  .map(backup => {
57
130
  const busy = isBackupBusy(backup, state);
58
131
  const canRestore = backup.status === 'verified' && backup.fileExists && !busy && !state.connections.active?.readOnly;
132
+ const canCompare =
133
+ backup.status === 'verified' && backup.fileExists && !busy && isBackupForActiveConnection(backup, state);
59
134
  const canDownload = backup.fileExists && !busy;
60
- const canEditNotes = !busy;
135
+ const canEdit = !busy;
61
136
  const canDelete = !busy;
62
137
 
63
138
  return `
64
- <tr class="border-b border-outline-variant/10 align-top">
65
- <td class="min-w-[20rem] px-4 py-5">
66
- <div class="space-y-2">
67
- <div class="font-headline text-sm font-black uppercase text-on-surface">${escapeHtml(backup.name)}</div>
139
+ <div class="grid min-w-[76rem] gap-5 px-4 py-5 xl:grid-cols-[minmax(18rem,1.2fr)_minmax(17rem,0.85fr)_minmax(18rem,1fr)_14rem]">
140
+ <div class="min-w-0 space-y-2">
141
+ <div class="break-words font-body text-sm font-black uppercase text-on-surface">${escapeHtml(backup.name)}</div>
68
142
  <div class="font-mono text-[10px] uppercase tracking-[0.12em] text-on-surface-variant/45" title="${escapeHtml(backup.path)}">
69
143
  ${escapeHtml(truncateMiddle(backup.fileName || backup.path, 48))}
70
144
  </div>
71
145
  <div class="font-mono text-[10px] uppercase tracking-[0.12em] text-on-surface-variant/55">
72
146
  Created // ${escapeHtml(formatCompactDateTime(backup.createdAt))}
73
147
  </div>
74
- </div>
75
- </td>
76
- <td class="px-4 py-5">
148
+ <div class="font-mono text-[10px] uppercase tracking-[0.12em] text-on-surface-variant/45">
149
+ PK // ${escapeHtml(backup.id)}
150
+ </div>
151
+ </div>
152
+ <div class="min-w-0">
77
153
  ${renderBackupMetadata(backup)}
78
- </td>
79
- <td class="min-w-[18rem] max-w-[24rem] px-4 py-5">
80
- <div class="flex h-full flex-col items-start gap-3">
81
- <div class="text-xs leading-6 text-on-surface-variant/70">
154
+ </div>
155
+ <div class="min-w-0">
156
+ <div class="mb-2 flex items-center justify-between gap-3">
157
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/45">Note</div>
158
+ <button class="standard-button flex-none" data-action="open-edit-backup-modal" data-backup-id="${escapeHtml(backup.id)}" type="button" ${canEdit ? '' : 'disabled'}>
159
+ <span class="material-symbols-outlined text-sm">edit_note</span>
160
+ Edit
161
+ </button>
162
+ </div>
163
+ <div class="min-h-20 border border-outline-variant/10 bg-surface-container-lowest px-3 py-3">
164
+ <div class="max-w-full whitespace-pre-wrap break-words text-xs leading-6 text-on-surface-variant/70 [overflow-wrap:anywhere]">
82
165
  ${backup.notes ? escapeHtml(backup.notes) : '<span class="text-on-surface-variant/35">No notes</span>'}
83
166
  ${backup.errorMessage ? `<div class="mt-2 text-error">${escapeHtml(backup.errorMessage)}</div>` : ''}
84
167
  </div>
85
- <button class="standard-button" data-action="open-edit-backup-notes-modal" data-backup-id="${escapeHtml(backup.id)}" type="button" ${canEditNotes ? '' : 'disabled'}>
86
- <span class="material-symbols-outlined text-sm">edit_note</span>
87
- Edit Notes
88
- </button>
89
168
  </div>
90
- </td>
91
- <td class="px-4 py-5">
92
- <div class="flex min-w-[10rem] flex-col items-stretch gap-2">
169
+ </div>
170
+ <div class="min-w-0">
171
+ <div class="flex min-w-0 flex-col items-stretch gap-2">
172
+ <button class="standard-button" data-action="open-compare-backup-drawer" data-backup-id="${escapeHtml(backup.id)}" type="button" ${canCompare ? '' : 'disabled'}>
173
+ <span class="material-symbols-outlined text-sm">difference</span>
174
+ Compare with current
175
+ </button>
93
176
  <button class="standard-button" data-action="open-restore-backup-modal" data-backup-id="${escapeHtml(backup.id)}" type="button" ${canRestore ? '' : 'disabled'}>
94
177
  <span class="material-symbols-outlined text-sm">restore</span>
95
178
  Restore
@@ -103,8 +186,8 @@ function renderBackupRows(state) {
103
186
  Delete
104
187
  </button>
105
188
  </div>
106
- </td>
107
- </tr>
189
+ </div>
190
+ </div>
108
191
  `;
109
192
  })
110
193
  .join('');
@@ -125,7 +208,7 @@ function renderBackupsBody(state) {
125
208
  if (state.backups.error) {
126
209
  return `
127
210
  <div class="border border-error/20 bg-error-container/10 px-6 py-5 text-sm text-on-surface">
128
- <div class="font-headline text-xs font-bold uppercase tracking-[0.18em] text-error">
211
+ <div class="font-body text-xs font-bold uppercase tracking-[0.18em] text-error">
129
212
  ${escapeHtml(state.backups.error.code)}
130
213
  </div>
131
214
  <div class="mt-2">${escapeHtml(state.backups.error.message)}</div>
@@ -137,7 +220,7 @@ function renderBackupsBody(state) {
137
220
  return `
138
221
  <div class="border border-dashed border-outline-variant/20 bg-surface-container-low px-8 py-10 text-center">
139
222
  <span class="material-symbols-outlined mb-3 text-5xl text-on-surface-variant/25">inventory_2</span>
140
- <p class="font-headline text-xl font-black uppercase tracking-tight text-primary-container">
223
+ <p class="font-body text-xl font-black uppercase tracking-tight text-primary-container">
141
224
  No backups yet
142
225
  </p>
143
226
  <p class="mx-auto mt-3 max-w-xl text-sm leading-7 text-on-surface-variant/65">
@@ -154,24 +237,485 @@ function renderBackupsBody(state) {
154
237
  }
155
238
 
156
239
  return `
240
+ ${renderBackupUsageSummary(state)}
157
241
  <div class="overflow-auto border border-outline-variant/10 bg-surface-container-low">
158
- <table class="min-w-full border-collapse text-left">
159
- <thead class="border-b border-outline-variant/10 bg-surface-container">
160
- <tr class="font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/55">
161
- <th class="px-4 py-3 font-normal">Backup</th>
162
- <th class="px-4 py-3 font-normal">Metadata</th>
163
- <th class="px-4 py-3 font-normal">Note</th>
164
- <th class="px-4 py-3 text-right font-normal">Actions</th>
242
+ <div class="grid min-w-[76rem] grid-cols-[minmax(18rem,1.2fr)_minmax(17rem,0.85fr)_minmax(18rem,1fr)_14rem] gap-5 border-b border-outline-variant/10 bg-surface-container px-4 py-3 font-mono text-[10px] uppercase tracking-[0.18em] text-on-surface-variant/55">
243
+ <div>Backup</div>
244
+ <div>Metadata</div>
245
+ <div>Note</div>
246
+ <div class="text-right">Actions</div>
247
+ </div>
248
+ <div class="divide-y divide-outline-variant/10">
249
+ ${renderBackupRows(state)}
250
+ </div>
251
+ </div>
252
+ `;
253
+ }
254
+
255
+ function formatDiffMetric(value) {
256
+ return value === null || value === undefined ? '-' : formatNumber(value);
257
+ }
258
+
259
+ function formatDiffValue(value) {
260
+ if (value && typeof value === 'object' && value.__type === 'integer') {
261
+ return value.value;
262
+ }
263
+
264
+ return formatCellValue(value);
265
+ }
266
+
267
+ function hasBackupDiffChanges(summary = {}) {
268
+ return Boolean(
269
+ Number(summary.schemaChanges ?? 0) ||
270
+ Number(summary.rowsAdded ?? 0) ||
271
+ Number(summary.rowsChanged ?? 0) ||
272
+ Number(summary.rowsRemoved ?? 0) ||
273
+ Number(summary.skippedTables ?? 0),
274
+ );
275
+ }
276
+
277
+ function renderBackupDiffMetaItem(label, value) {
278
+ return `
279
+ <div class="border border-outline-variant/10 bg-surface-container px-3 py-3">
280
+ <div class="font-mono text-[10px] uppercase tracking-[0.16em] text-on-surface-variant/55">
281
+ ${escapeHtml(label)}
282
+ </div>
283
+ <div class="mt-2 break-words text-sm text-on-surface">${escapeHtml(value || '-')}</div>
284
+ </div>
285
+ `;
286
+ }
287
+
288
+ function renderBackupDiffSummary(summary = {}) {
289
+ const items = [
290
+ ['Schema changes', summary.schemaChanges],
291
+ ['Rows added', summary.rowsAdded],
292
+ ['Rows changed', summary.rowsChanged],
293
+ ['Rows removed', summary.rowsRemoved],
294
+ ['Skipped tables', summary.skippedTables],
295
+ ];
296
+
297
+ return `
298
+ <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
299
+ ${items.map(([label, value]) => renderBackupDiffMetaItem(label, formatDiffMetric(value))).join('')}
300
+ </div>
301
+ `;
302
+ }
303
+
304
+ function renderBackupDiffTabs(activeTab) {
305
+ return `
306
+ <div class="mt-5 flex flex-col items-start gap-2">
307
+ <div class="charts-height-toggle" role="group" aria-label="Backup diff view">
308
+ <button
309
+ class="standard-button charts-height-toggle__button ${activeTab === 'schema' ? 'is-active' : ''}"
310
+ aria-pressed="${activeTab === 'schema' ? 'true' : 'false'}"
311
+ data-action="set-backup-diff-tab"
312
+ data-tab="schema"
313
+ type="button"
314
+ >
315
+ Schema
316
+ </button>
317
+ <button
318
+ class="standard-button charts-height-toggle__button ${activeTab === 'data' ? 'is-active' : ''}"
319
+ aria-pressed="${activeTab === 'data' ? 'true' : 'false'}"
320
+ data-action="set-backup-diff-tab"
321
+ data-tab="data"
322
+ type="button"
323
+ >
324
+ Data
325
+ </button>
326
+ </div>
327
+ </div>
328
+ `;
329
+ }
330
+
331
+ function getSchemaObjectLabel(objectType) {
332
+ return (
333
+ {
334
+ table: 'table',
335
+ column: 'column',
336
+ index: 'index',
337
+ foreign_key: 'foreign key',
338
+ view: 'view',
339
+ trigger: 'trigger',
340
+ }[objectType] || objectType || 'object'
341
+ );
342
+ }
343
+
344
+ function getSchemaActionIcon(action) {
345
+ return (
346
+ {
347
+ added: '+',
348
+ changed: '~',
349
+ removed: '-',
350
+ }[action] || '*'
351
+ );
352
+ }
353
+
354
+ function getSchemaActionLabel(action) {
355
+ return (
356
+ {
357
+ added: 'Added',
358
+ changed: 'Changed',
359
+ removed: 'Removed',
360
+ }[action] || 'Updated'
361
+ );
362
+ }
363
+
364
+ function renderSchemaDefinitionPair(change) {
365
+ if (!change.before && !change.after) {
366
+ return '';
367
+ }
368
+
369
+ return `
370
+ <div class="mt-2 grid gap-2 text-[11px]">
371
+ <div class="border border-outline-variant/10 bg-surface-container px-3 py-2">
372
+ <div class="font-mono text-[9px] uppercase tracking-[0.16em] text-on-surface-variant/45">Backup</div>
373
+ <div class="mt-1 whitespace-pre-wrap break-words font-mono text-on-surface-variant/75 [overflow-wrap:anywhere]">${escapeHtml(change.before || '-')}</div>
374
+ </div>
375
+ <div class="border border-outline-variant/10 bg-surface-container px-3 py-2">
376
+ <div class="font-mono text-[9px] uppercase tracking-[0.16em] text-on-surface-variant/45">Current</div>
377
+ <div class="mt-1 whitespace-pre-wrap break-words font-mono text-on-surface-variant/75 [overflow-wrap:anywhere]">${escapeHtml(change.after || '-')}</div>
378
+ </div>
379
+ </div>
380
+ `;
381
+ }
382
+
383
+ function renderSchemaChange(change) {
384
+ const name = change.name || change.definition || getSchemaObjectLabel(change.objectType);
385
+ const detail =
386
+ change.action === 'changed'
387
+ ? ''
388
+ : change.definition && change.definition !== name
389
+ ? `: ${change.definition}`
390
+ : '';
391
+
392
+ return `
393
+ <div class="border-l border-outline-variant/20 pl-3">
394
+ <div class="font-mono text-[11px] uppercase tracking-[0.12em] text-on-surface/80">
395
+ ${escapeHtml(getSchemaActionIcon(change.action))}
396
+ ${escapeHtml(getSchemaActionLabel(change.action))}
397
+ ${escapeHtml(getSchemaObjectLabel(change.objectType))}: ${escapeHtml(name)}${escapeHtml(detail)}
398
+ </div>
399
+ ${renderSchemaDefinitionPair(change)}
400
+ </div>
401
+ `;
402
+ }
403
+
404
+ function renderSchemaEntry(entry, fallbackAction) {
405
+ const changes = Array.isArray(entry.changes)
406
+ ? entry.changes
407
+ : [
408
+ {
409
+ action: fallbackAction,
410
+ objectType: entry.type,
411
+ name: entry.name,
412
+ definition: entry.definition,
413
+ },
414
+ ];
415
+
416
+ return `
417
+ <div class="border border-outline-variant/10 bg-surface-container-lowest px-4 py-4">
418
+ <div class="break-words font-body text-sm font-black uppercase text-on-surface">${escapeHtml(entry.name)}</div>
419
+ <div class="mt-3 space-y-3">
420
+ ${changes.map(renderSchemaChange).join('')}
421
+ </div>
422
+ </div>
423
+ `;
424
+ }
425
+
426
+ function renderSchemaGroup(title, entries, fallbackAction) {
427
+ return `
428
+ <section class="space-y-3">
429
+ <div class="font-mono text-[10px] uppercase tracking-[0.22em] text-on-surface-variant/55">${escapeHtml(title)}</div>
430
+ ${
431
+ entries?.length
432
+ ? `<div class="space-y-3">${entries.map(entry => renderSchemaEntry(entry, fallbackAction)).join('')}</div>`
433
+ : '<div class="border border-dashed border-outline-variant/15 bg-surface-container px-4 py-3 text-sm text-on-surface-variant/50">No changes in this group.</div>'
434
+ }
435
+ </section>
436
+ `;
437
+ }
438
+
439
+ function renderBackupDiffSchema(diff) {
440
+ const schema = diff?.schema ?? { added: [], changed: [], removed: [] };
441
+
442
+ return `
443
+ <div class="mt-5 space-y-5">
444
+ ${renderSchemaGroup('Added', schema.added, 'added')}
445
+ ${renderSchemaGroup('Changed', schema.changed, 'changed')}
446
+ ${renderSchemaGroup('Removed', schema.removed, 'removed')}
447
+ </div>
448
+ `;
449
+ }
450
+
451
+ function renderSampleValueRows(sample, valueLabel) {
452
+ const entries = Object.entries(sample.values ?? {});
453
+
454
+ if (!entries.length) {
455
+ return '';
456
+ }
457
+
458
+ return `
459
+ <div class="overflow-auto border border-outline-variant/10">
460
+ <table class="min-w-full border-collapse text-left text-xs">
461
+ <thead class="bg-surface-container">
462
+ <tr class="font-mono text-[10px] uppercase tracking-[0.16em] text-on-surface-variant/55">
463
+ <th class="px-3 py-2 font-normal">Column</th>
464
+ <th class="px-3 py-2 font-normal">${escapeHtml(valueLabel)}</th>
165
465
  </tr>
166
466
  </thead>
167
467
  <tbody>
168
- ${renderBackupRows(state)}
468
+ ${entries
469
+ .map(
470
+ ([column, value]) => `
471
+ <tr class="border-t border-outline-variant/10">
472
+ <td class="px-3 py-2 font-mono text-on-surface-variant/70">${escapeHtml(column)}</td>
473
+ <td class="px-3 py-2 text-on-surface">${escapeHtml(formatDiffValue(value))}</td>
474
+ </tr>
475
+ `,
476
+ )
477
+ .join('')}
169
478
  </tbody>
170
479
  </table>
171
480
  </div>
172
481
  `;
173
482
  }
174
483
 
484
+ function renderChangedRowSample(sample) {
485
+ return `
486
+ <div class="space-y-2 border border-outline-variant/10 bg-surface-container-lowest px-4 py-3">
487
+ <div class="break-words font-body text-sm font-black text-on-surface">${escapeHtml(sample.identityLabel || 'Row')}</div>
488
+ <div class="overflow-auto border border-outline-variant/10">
489
+ <table class="min-w-full border-collapse text-left text-xs">
490
+ <thead class="bg-surface-container">
491
+ <tr class="font-mono text-[10px] uppercase tracking-[0.16em] text-on-surface-variant/55">
492
+ <th class="px-3 py-2 font-normal">Column</th>
493
+ <th class="px-3 py-2 font-normal">Backup</th>
494
+ <th class="px-3 py-2 font-normal">Current</th>
495
+ </tr>
496
+ </thead>
497
+ <tbody>
498
+ ${(sample.columns ?? [])
499
+ .map(
500
+ column => `
501
+ <tr class="border-t border-outline-variant/10">
502
+ <td class="px-3 py-2 font-mono text-on-surface-variant/70">${escapeHtml(column.name)}</td>
503
+ <td class="px-3 py-2 text-on-surface">${escapeHtml(formatDiffValue(column.backup))}</td>
504
+ <td class="px-3 py-2 text-on-surface">${escapeHtml(formatDiffValue(column.current))}</td>
505
+ </tr>
506
+ `,
507
+ )
508
+ .join('')}
509
+ </tbody>
510
+ </table>
511
+ </div>
512
+ </div>
513
+ `;
514
+ }
515
+
516
+ function renderAddedOrRemovedSample(sample, valueLabel) {
517
+ return `
518
+ <div class="space-y-2 border border-outline-variant/10 bg-surface-container-lowest px-4 py-3">
519
+ ${sample.identityLabel ? `<div class="break-words font-body text-sm font-black text-on-surface">${escapeHtml(sample.identityLabel)}</div>` : ''}
520
+ ${renderSampleValueRows(sample, valueLabel)}
521
+ </div>
522
+ `;
523
+ }
524
+
525
+ function renderDataSampleGroup(title, samples, renderer) {
526
+ if (!samples?.length) {
527
+ return '';
528
+ }
529
+
530
+ return `
531
+ <section class="space-y-3">
532
+ <div class="font-mono text-[10px] uppercase tracking-[0.2em] text-on-surface-variant/50">${escapeHtml(title)}</div>
533
+ <div class="space-y-3">${samples.map(renderer).join('')}</div>
534
+ </section>
535
+ `;
536
+ }
537
+
538
+ function renderDataTableDetails(table) {
539
+ const sampleMarkup = [
540
+ renderDataSampleGroup('Rows added', table.samples?.added, sample =>
541
+ renderAddedOrRemovedSample(sample, 'Current'),
542
+ ),
543
+ renderDataSampleGroup('Rows changed', table.samples?.changed, renderChangedRowSample),
544
+ renderDataSampleGroup('Rows removed', table.samples?.removed, sample =>
545
+ renderAddedOrRemovedSample(sample, 'Backup'),
546
+ ),
547
+ ]
548
+ .filter(Boolean)
549
+ .join('');
550
+
551
+ if (!sampleMarkup) {
552
+ return `
553
+ <div class="border-t border-outline-variant/10 px-4 py-3 text-sm text-on-surface-variant/50">
554
+ No sample rows returned for this table.
555
+ </div>
556
+ `;
557
+ }
558
+
559
+ return `<div class="space-y-4 border-t border-outline-variant/10 px-4 py-4">${sampleMarkup}</div>`;
560
+ }
561
+
562
+ function renderDataTableRow(table) {
563
+ const canExpand =
564
+ table.status === 'comparable' &&
565
+ ((table.samples?.added?.length ?? 0) ||
566
+ (table.samples?.changed?.length ?? 0) ||
567
+ (table.samples?.removed?.length ?? 0));
568
+ const summary = `
569
+ <div class="grid grid-cols-[minmax(10rem,1fr)_4rem_4rem_4rem] items-center gap-3 px-4 py-3">
570
+ <div class="min-w-0">
571
+ <div class="break-words font-body text-sm font-black uppercase text-on-surface">${escapeHtml(table.name)}</div>
572
+ <div class="mt-1 text-xs text-on-surface-variant/60" title="${escapeHtml(table.reason ?? '')}">
573
+ ${escapeHtml(table.statusLabel || table.status)}
574
+ </div>
575
+ </div>
576
+ <div class="font-mono text-xs text-on-surface">${escapeHtml(formatDiffMetric(table.added))}</div>
577
+ <div class="font-mono text-xs text-on-surface">${escapeHtml(formatDiffMetric(table.changed))}</div>
578
+ <div class="font-mono text-xs text-on-surface">${escapeHtml(formatDiffMetric(table.removed))}</div>
579
+ </div>
580
+ `;
581
+
582
+ if (!canExpand) {
583
+ return `<div class="border-t border-outline-variant/10">${summary}</div>`;
584
+ }
585
+
586
+ return `
587
+ <details class="border-t border-outline-variant/10">
588
+ <summary class="cursor-pointer list-none">${summary}</summary>
589
+ ${renderDataTableDetails(table)}
590
+ </details>
591
+ `;
592
+ }
593
+
594
+ function renderBackupDiffData(diff) {
595
+ const tables = diff?.data?.tables ?? [];
596
+
597
+ if (!tables.length) {
598
+ return `
599
+ <div class="mt-5 border border-dashed border-outline-variant/15 bg-surface-container px-4 py-3 text-sm text-on-surface-variant/50">
600
+ No user tables were found in either database state.
601
+ </div>
602
+ `;
603
+ }
604
+
605
+ return `
606
+ <div class="mt-5 overflow-auto border border-outline-variant/10 bg-surface-container-lowest">
607
+ <div class="grid grid-cols-[minmax(10rem,1fr)_4rem_4rem_4rem] gap-3 bg-surface-container px-4 py-3 font-mono text-[10px] uppercase tracking-[0.16em] text-on-surface-variant/55">
608
+ <div>Table</div>
609
+ <div>Added</div>
610
+ <div>Changed</div>
611
+ <div>Removed</div>
612
+ </div>
613
+ ${tables.map(renderDataTableRow).join('')}
614
+ </div>
615
+ `;
616
+ }
617
+
618
+ function renderBackupDiffDrawerBody(diffState) {
619
+ if (diffState.loading) {
620
+ return `
621
+ <div class="flex flex-1 items-center justify-center px-6 text-[10px] font-mono uppercase tracking-[0.18em] text-on-surface-variant/55">
622
+ Comparing backup...
623
+ </div>
624
+ `;
625
+ }
626
+
627
+ if (diffState.error) {
628
+ return `
629
+ <div class="p-5">
630
+ <div class="border border-error/30 bg-error-container/20 px-4 py-4 text-sm text-error">
631
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em]">${escapeHtml(diffState.error.code || 'REQUEST_FAILED')}</div>
632
+ <div class="mt-2">${escapeHtml(diffState.error.message || 'Backup comparison failed.')}</div>
633
+ </div>
634
+ </div>
635
+ `;
636
+ }
637
+
638
+ const diff = diffState.data;
639
+
640
+ if (!diff) {
641
+ return '';
642
+ }
643
+
644
+ const activeTab = diffState.activeTab === 'data' ? 'data' : 'schema';
645
+
646
+ return `
647
+ <div class="custom-scrollbar min-h-0 flex-1 overflow-auto px-5 py-5">
648
+ <div class="grid grid-cols-1 gap-3">
649
+ ${renderBackupDiffMetaItem('Created', formatDateTime(diff.backup?.createdAt ?? diffState.backupCreatedAt))}
650
+ ${renderBackupDiffMetaItem('Current', diff.current?.label || diffState.currentLabel || 'Current database')}
651
+ </div>
652
+ <section class="mt-5">
653
+ ${renderBackupDiffSummary(diff.summary)}
654
+ </section>
655
+ ${
656
+ hasBackupDiffChanges(diff.summary)
657
+ ? ''
658
+ : `
659
+ <div class="mt-5 border border-outline-variant/10 bg-surface-container px-4 py-5 text-center">
660
+ <span class="material-symbols-outlined mb-2 text-4xl text-primary-container/60">check_circle</span>
661
+ <div class="font-body text-sm font-black uppercase text-on-surface">No changes found</div>
662
+ <p class="mt-2 text-sm leading-6 text-on-surface-variant/65">
663
+ The verified backup and current database match.
664
+ </p>
665
+ </div>
666
+ `
667
+ }
668
+ ${renderBackupDiffTabs(activeTab)}
669
+ ${activeTab === 'schema' ? renderBackupDiffSchema(diff) : renderBackupDiffData(diff)}
670
+ </div>
671
+ `;
672
+ }
673
+
674
+ export function renderBackupDiffDrawer(state) {
675
+ const diffState = state.backups.diff;
676
+
677
+ if (!diffState?.visible) {
678
+ return '';
679
+ }
680
+
681
+ const title = diffState.data?.backup?.name ?? diffState.backupName ?? 'Backup comparison';
682
+ const currentLabel = diffState.data?.current?.label ?? diffState.currentLabel ?? 'Current database';
683
+
684
+ return `
685
+ <section class="flex h-full min-h-0 flex-col bg-surface-low">
686
+ <div class="border-b border-outline-variant/10 px-5 py-4">
687
+ <div class="flex items-center justify-between gap-3">
688
+ <div class="min-w-0">
689
+ <div class="font-mono text-[10px] uppercase tracking-[0.18em] text-primary-container/70">
690
+ Backup Compare
691
+ </div>
692
+ <h2 class="mt-1 truncate font-body text-lg font-black uppercase tracking-tight text-on-surface">
693
+ ${escapeHtml(title)}
694
+ </h2>
695
+ <div class="mt-1 truncate text-xs text-on-surface-variant/60">
696
+ Current // ${escapeHtml(currentLabel)}
697
+ </div>
698
+ </div>
699
+ <button
700
+ class="query-history-icon-button"
701
+ data-action="close-backup-diff-drawer"
702
+ title="Close backup comparison"
703
+ type="button"
704
+ >
705
+ <span class="material-symbols-outlined text-[18px]">close</span>
706
+ </button>
707
+ </div>
708
+ <div class="mt-4 flex flex-wrap gap-2">
709
+ ${renderStatusBadge('read only', 'muted')}
710
+ ${diffState.loading ? renderStatusBadge('loading', 'muted') : ''}
711
+ ${diffState.error ? renderStatusBadge('error', 'alert') : ''}
712
+ </div>
713
+ </div>
714
+ ${renderBackupDiffDrawerBody(diffState)}
715
+ </section>
716
+ `;
717
+ }
718
+
175
719
  export function renderBackupsView(state) {
176
720
  const activeLabel = state.connections.active?.label ?? 'No active database';
177
721
  const actions = `
@@ -199,6 +743,6 @@ export function renderBackupsView(state) {
199
743
  </div>
200
744
  </section>
201
745
  `,
202
- panel: '',
746
+ panel: renderBackupDiffDrawer(state),
203
747
  };
204
748
  }