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
@@ -0,0 +1,914 @@
1
+ const { ValidationError } = require("../../utils/errors");
2
+ const { quoteIdentifier } = require("../../utils/identifier");
3
+
4
+ const DEFAULT_SAMPLE_LIMIT = 25;
5
+ const MIN_SAMPLE_LIMIT = 1;
6
+ const MAX_SAMPLE_LIMIT = 100;
7
+ const USER_SCHEMA_TYPES = new Set(["table", "index", "view", "trigger"]);
8
+
9
+ function normalizeBackupDiffSampleLimit(value) {
10
+ if (value === undefined) {
11
+ return DEFAULT_SAMPLE_LIMIT;
12
+ }
13
+
14
+ const text = String(value).trim();
15
+
16
+ if (!/^\d+$/.test(text)) {
17
+ throw new ValidationError("sampleLimit must be an integer between 1 and 100.");
18
+ }
19
+
20
+ const limit = Number(text);
21
+
22
+ if (limit < MIN_SAMPLE_LIMIT || limit > MAX_SAMPLE_LIMIT) {
23
+ throw new ValidationError("sampleLimit must be an integer between 1 and 100.");
24
+ }
25
+
26
+ return limit;
27
+ }
28
+
29
+ function normalizeSqlDefinition(sql = "") {
30
+ return String(sql ?? "")
31
+ .replace(/\s+/g, " ")
32
+ .replace(/;\s*$/, "")
33
+ .trim()
34
+ .toLowerCase();
35
+ }
36
+
37
+ function normalizeType(value) {
38
+ return String(value ?? "")
39
+ .replace(/\s+/g, " ")
40
+ .trim();
41
+ }
42
+
43
+ function serializeSqliteValue(value) {
44
+ if (Buffer.isBuffer(value)) {
45
+ return {
46
+ __type: "blob",
47
+ sizeBytes: value.length,
48
+ hexPreview: value.subarray(0, 16).toString("hex"),
49
+ };
50
+ }
51
+
52
+ if (typeof value === "bigint") {
53
+ return {
54
+ __type: "integer",
55
+ value: value.toString(),
56
+ };
57
+ }
58
+
59
+ return value;
60
+ }
61
+
62
+ function valueComparisonKey(value) {
63
+ if (Buffer.isBuffer(value)) {
64
+ return `blob:${value.toString("base64")}`;
65
+ }
66
+
67
+ if (typeof value === "bigint") {
68
+ return `bigint:${value.toString()}`;
69
+ }
70
+
71
+ return JSON.stringify([typeof value, value]);
72
+ }
73
+
74
+ function valuesEqual(left, right) {
75
+ return valueComparisonKey(left) === valueComparisonKey(right);
76
+ }
77
+
78
+ function buildColumnDefinition(column) {
79
+ const parts = [];
80
+
81
+ if (column.declaredType) {
82
+ parts.push(column.declaredType);
83
+ }
84
+
85
+ if (column.notNull) {
86
+ parts.push("NOT NULL");
87
+ }
88
+
89
+ if (column.defaultValue !== null && column.defaultValue !== undefined) {
90
+ parts.push(`DEFAULT ${column.defaultValue}`);
91
+ }
92
+
93
+ if (column.primaryKeyPosition > 0) {
94
+ parts.push(
95
+ column.primaryKeyPosition === 1
96
+ ? "PRIMARY KEY"
97
+ : `PRIMARY KEY (${column.primaryKeyPosition})`
98
+ );
99
+ }
100
+
101
+ if (column.generated) {
102
+ parts.push("GENERATED");
103
+ }
104
+
105
+ if (!column.visible) {
106
+ parts.push("HIDDEN");
107
+ }
108
+
109
+ return parts.join(" ") || "value";
110
+ }
111
+
112
+ function columnSignature(column) {
113
+ return JSON.stringify({
114
+ declaredType: column.declaredType.toLowerCase(),
115
+ notNull: column.notNull,
116
+ defaultValue: column.defaultValue,
117
+ primaryKeyPosition: column.primaryKeyPosition,
118
+ hidden: column.hidden,
119
+ });
120
+ }
121
+
122
+ function readTableColumns(db, tableName) {
123
+ return db
124
+ .prepare(`PRAGMA table_xinfo(${quoteIdentifier(tableName)})`)
125
+ .all()
126
+ .map((row) => {
127
+ const hidden = Number(row.hidden ?? 0);
128
+ const column = {
129
+ cid: Number(row.cid ?? 0),
130
+ name: row.name,
131
+ declaredType: normalizeType(row.type),
132
+ notNull: Boolean(row.notnull),
133
+ defaultValue: row.dflt_value,
134
+ primaryKeyPosition: Number(row.pk ?? 0),
135
+ hidden,
136
+ visible: hidden === 0,
137
+ generated: hidden === 2 || hidden === 3,
138
+ };
139
+
140
+ return {
141
+ ...column,
142
+ definition: buildColumnDefinition(column),
143
+ signature: columnSignature(column),
144
+ };
145
+ });
146
+ }
147
+
148
+ function indexDefinition(index) {
149
+ if (index.sql) {
150
+ return index.sql;
151
+ }
152
+
153
+ const unique = index.unique ? "UNIQUE " : "";
154
+ const columns = index.columns.length
155
+ ? index.columns.map((column) => quoteIdentifier(column)).join(", ")
156
+ : "expression";
157
+
158
+ return `CREATE ${unique}INDEX ${quoteIdentifier(index.name)} ON ${quoteIdentifier(
159
+ index.tableName
160
+ )} (${columns})`;
161
+ }
162
+
163
+ function readTableIndexes(db, tableName, schemaEntriesByName) {
164
+ return db
165
+ .prepare(`PRAGMA index_list(${quoteIdentifier(tableName)})`)
166
+ .all()
167
+ .filter((row) => row.name)
168
+ .map((row) => {
169
+ const columns = db
170
+ .prepare(`PRAGMA index_xinfo(${quoteIdentifier(row.name)})`)
171
+ .all()
172
+ .filter((entry) => Number(entry.key ?? 0) === 1 && Number(entry.cid ?? -1) >= 0)
173
+ .sort((left, right) => Number(left.seqno ?? 0) - Number(right.seqno ?? 0))
174
+ .map((entry) => entry.name);
175
+ const schemaEntry = schemaEntriesByName.get(row.name) ?? {};
176
+ const index = {
177
+ name: row.name,
178
+ tableName,
179
+ unique: Boolean(row.unique),
180
+ partial: Boolean(row.partial),
181
+ origin: row.origin ?? null,
182
+ internal: String(row.name).startsWith("sqlite_"),
183
+ columns,
184
+ sql: schemaEntry.sql ?? null,
185
+ };
186
+
187
+ return {
188
+ ...index,
189
+ definition: indexDefinition(index),
190
+ signature: JSON.stringify({
191
+ unique: index.unique,
192
+ partial: index.partial,
193
+ columns: index.columns,
194
+ sql: normalizeSqlDefinition(index.sql),
195
+ }),
196
+ };
197
+ });
198
+ }
199
+
200
+ function groupForeignKeys(rows) {
201
+ const grouped = new Map();
202
+
203
+ rows.forEach((row) => {
204
+ const id = Number(row.id ?? 0);
205
+ const group = grouped.get(id) ?? {
206
+ id,
207
+ table: row.table,
208
+ from: [],
209
+ to: [],
210
+ onUpdate: row.on_update ?? "NO ACTION",
211
+ onDelete: row.on_delete ?? "NO ACTION",
212
+ match: row.match ?? "NONE",
213
+ };
214
+
215
+ group.from[Number(row.seq ?? 0)] = row.from;
216
+ group.to[Number(row.seq ?? 0)] = row.to;
217
+ grouped.set(id, group);
218
+ });
219
+
220
+ return [...grouped.values()].map((foreignKey) => {
221
+ const targetColumns = foreignKey.to.filter(Boolean);
222
+ const targetColumnList = targetColumns.length
223
+ ? ` (${targetColumns.map((column) => quoteIdentifier(column)).join(", ")})`
224
+ : "";
225
+ const definition = [
226
+ `FOREIGN KEY (${foreignKey.from.map((column) => quoteIdentifier(column)).join(", ")})`,
227
+ `REFERENCES ${quoteIdentifier(foreignKey.table)}${targetColumnList}`,
228
+ `ON UPDATE ${foreignKey.onUpdate}`,
229
+ `ON DELETE ${foreignKey.onDelete}`,
230
+ ].join(" ");
231
+
232
+ return {
233
+ ...foreignKey,
234
+ definition,
235
+ signature: JSON.stringify({
236
+ table: foreignKey.table,
237
+ from: foreignKey.from,
238
+ to: foreignKey.to,
239
+ onUpdate: foreignKey.onUpdate,
240
+ onDelete: foreignKey.onDelete,
241
+ match: foreignKey.match,
242
+ }),
243
+ };
244
+ });
245
+ }
246
+
247
+ function readTableForeignKeys(db, tableName) {
248
+ return groupForeignKeys(db.prepare(`PRAGMA foreign_key_list(${quoteIdentifier(tableName)})`).all());
249
+ }
250
+
251
+ function readSchemaSnapshot(db) {
252
+ const schemaEntries = db
253
+ .prepare(
254
+ [
255
+ "SELECT type, name, tbl_name AS tableName, sql",
256
+ "FROM sqlite_schema",
257
+ "WHERE name NOT LIKE 'sqlite_%'",
258
+ "ORDER BY type ASC, name ASC",
259
+ ].join(" ")
260
+ )
261
+ .all()
262
+ .filter((entry) => USER_SCHEMA_TYPES.has(entry.type));
263
+ const schemaEntriesByName = new Map(schemaEntries.map((entry) => [entry.name, entry]));
264
+ const tables = new Map();
265
+ const views = new Map();
266
+ const triggers = new Map();
267
+
268
+ schemaEntries.forEach((entry) => {
269
+ if (entry.type === "table") {
270
+ const columns = readTableColumns(db, entry.name);
271
+ const allIndexes = readTableIndexes(db, entry.name, schemaEntriesByName);
272
+ const indexes = allIndexes.filter((index) => !index.internal);
273
+ const foreignKeys = readTableForeignKeys(db, entry.name);
274
+
275
+ tables.set(entry.name, {
276
+ type: "table",
277
+ name: entry.name,
278
+ sql: entry.sql ?? "",
279
+ normalizedSql: normalizeSqlDefinition(entry.sql),
280
+ columns,
281
+ indexes,
282
+ keyIndexes: allIndexes,
283
+ foreignKeys,
284
+ });
285
+ } else if (entry.type === "view") {
286
+ views.set(entry.name, {
287
+ type: "view",
288
+ name: entry.name,
289
+ sql: entry.sql ?? "",
290
+ normalizedSql: normalizeSqlDefinition(entry.sql),
291
+ });
292
+ } else if (entry.type === "trigger") {
293
+ triggers.set(entry.name, {
294
+ type: "trigger",
295
+ name: entry.name,
296
+ tableName: entry.tableName,
297
+ sql: entry.sql ?? "",
298
+ normalizedSql: normalizeSqlDefinition(entry.sql),
299
+ });
300
+ }
301
+ });
302
+
303
+ return {
304
+ tables,
305
+ views,
306
+ triggers,
307
+ };
308
+ }
309
+
310
+ function mapByName(entries = []) {
311
+ return new Map(entries.map((entry) => [entry.name, entry]));
312
+ }
313
+
314
+ function buildSchemaChange(action, objectType, entry, extra = {}) {
315
+ return {
316
+ action,
317
+ objectType,
318
+ name: entry.name,
319
+ definition: entry.definition ?? entry.sql ?? "",
320
+ ...extra,
321
+ };
322
+ }
323
+
324
+ function appendNamedObjectChanges(changes, baseEntries, currentEntries, objectType) {
325
+ const baseByName = mapByName(baseEntries);
326
+ const currentByName = mapByName(currentEntries);
327
+
328
+ currentEntries.forEach((entry) => {
329
+ const previous = baseByName.get(entry.name);
330
+
331
+ if (!previous) {
332
+ changes.push(buildSchemaChange("added", objectType, entry));
333
+ return;
334
+ }
335
+
336
+ if (previous.signature !== entry.signature) {
337
+ changes.push(
338
+ buildSchemaChange("changed", objectType, entry, {
339
+ before: previous.definition ?? previous.sql ?? "",
340
+ after: entry.definition ?? entry.sql ?? "",
341
+ })
342
+ );
343
+ }
344
+ });
345
+
346
+ baseEntries.forEach((entry) => {
347
+ if (!currentByName.has(entry.name)) {
348
+ changes.push(buildSchemaChange("removed", objectType, entry));
349
+ }
350
+ });
351
+ }
352
+
353
+ function appendForeignKeyChanges(changes, baseForeignKeys, currentForeignKeys) {
354
+ const baseBySignature = new Map(baseForeignKeys.map((entry) => [entry.signature, entry]));
355
+ const currentBySignature = new Map(currentForeignKeys.map((entry) => [entry.signature, entry]));
356
+
357
+ currentForeignKeys.forEach((entry) => {
358
+ if (!baseBySignature.has(entry.signature)) {
359
+ changes.push(buildSchemaChange("added", "foreign_key", entry));
360
+ }
361
+ });
362
+
363
+ baseForeignKeys.forEach((entry) => {
364
+ if (!currentBySignature.has(entry.signature)) {
365
+ changes.push(buildSchemaChange("removed", "foreign_key", entry));
366
+ }
367
+ });
368
+ }
369
+
370
+ function appendColumnChanges(changes, baseColumns, currentColumns) {
371
+ const baseByName = mapByName(baseColumns);
372
+ const currentByName = mapByName(currentColumns);
373
+
374
+ currentColumns.forEach((column) => {
375
+ const previous = baseByName.get(column.name);
376
+
377
+ if (!previous) {
378
+ changes.push(buildSchemaChange("added", "column", column));
379
+ return;
380
+ }
381
+
382
+ if (previous.signature !== column.signature) {
383
+ changes.push(
384
+ buildSchemaChange("changed", "column", column, {
385
+ before: previous.definition,
386
+ after: column.definition,
387
+ })
388
+ );
389
+ }
390
+ });
391
+
392
+ baseColumns.forEach((column) => {
393
+ if (!currentByName.has(column.name)) {
394
+ changes.push(buildSchemaChange("removed", "column", column));
395
+ }
396
+ });
397
+ }
398
+
399
+ function diffTopLevelSchemaObjects(schema, baseObjects, currentObjects, objectType) {
400
+ currentObjects.forEach((entry, name) => {
401
+ const previous = baseObjects.get(name);
402
+
403
+ if (!previous) {
404
+ schema.added.push({
405
+ type: objectType,
406
+ name,
407
+ definition: entry.sql,
408
+ });
409
+ return;
410
+ }
411
+
412
+ if (previous.normalizedSql !== entry.normalizedSql) {
413
+ schema.changed.push({
414
+ type: objectType,
415
+ name,
416
+ before: previous.sql,
417
+ after: entry.sql,
418
+ changes: [
419
+ {
420
+ action: "changed",
421
+ objectType,
422
+ name,
423
+ before: previous.sql,
424
+ after: entry.sql,
425
+ },
426
+ ],
427
+ });
428
+ }
429
+ });
430
+
431
+ baseObjects.forEach((entry, name) => {
432
+ if (!currentObjects.has(name)) {
433
+ schema.removed.push({
434
+ type: objectType,
435
+ name,
436
+ definition: entry.sql,
437
+ });
438
+ }
439
+ });
440
+ }
441
+
442
+ function buildSchemaDiff(baseSchema, currentSchema) {
443
+ const schema = {
444
+ added: [],
445
+ changed: [],
446
+ removed: [],
447
+ };
448
+
449
+ currentSchema.tables.forEach((table, name) => {
450
+ const previous = baseSchema.tables.get(name);
451
+
452
+ if (!previous) {
453
+ schema.added.push({
454
+ type: "table",
455
+ name,
456
+ definition: table.sql,
457
+ columns: table.columns.map((column) => ({
458
+ name: column.name,
459
+ definition: column.definition,
460
+ })),
461
+ indexes: table.indexes.map((index) => ({
462
+ name: index.name,
463
+ definition: index.definition,
464
+ })),
465
+ foreignKeys: table.foreignKeys.map((foreignKey) => ({
466
+ definition: foreignKey.definition,
467
+ })),
468
+ });
469
+ return;
470
+ }
471
+
472
+ const changes = [];
473
+ appendColumnChanges(changes, previous.columns, table.columns);
474
+ appendNamedObjectChanges(changes, previous.indexes, table.indexes, "index");
475
+ appendForeignKeyChanges(changes, previous.foreignKeys, table.foreignKeys);
476
+
477
+ if (!changes.length && previous.normalizedSql !== table.normalizedSql) {
478
+ changes.push({
479
+ action: "changed",
480
+ objectType: "table",
481
+ name,
482
+ before: previous.sql,
483
+ after: table.sql,
484
+ });
485
+ }
486
+
487
+ if (changes.length) {
488
+ schema.changed.push({
489
+ type: "table",
490
+ name,
491
+ changes,
492
+ });
493
+ }
494
+ });
495
+
496
+ baseSchema.tables.forEach((table, name) => {
497
+ if (!currentSchema.tables.has(name)) {
498
+ schema.removed.push({
499
+ type: "table",
500
+ name,
501
+ definition: table.sql,
502
+ columns: table.columns.map((column) => ({
503
+ name: column.name,
504
+ definition: column.definition,
505
+ })),
506
+ indexes: table.indexes.map((index) => ({
507
+ name: index.name,
508
+ definition: index.definition,
509
+ })),
510
+ foreignKeys: table.foreignKeys.map((foreignKey) => ({
511
+ definition: foreignKey.definition,
512
+ })),
513
+ });
514
+ }
515
+ });
516
+
517
+ diffTopLevelSchemaObjects(schema, baseSchema.views, currentSchema.views, "view");
518
+ diffTopLevelSchemaObjects(schema, baseSchema.triggers, currentSchema.triggers, "trigger");
519
+
520
+ return schema;
521
+ }
522
+
523
+ function getVisibleColumnNames(table) {
524
+ return table.columns.filter((column) => column.visible).map((column) => column.name);
525
+ }
526
+
527
+ function getStableKeyCandidates(table) {
528
+ const columnsByName = mapByName(table.columns);
529
+ const primaryKeyColumns = table.columns
530
+ .filter((column) => column.primaryKeyPosition > 0)
531
+ .sort((left, right) => left.primaryKeyPosition - right.primaryKeyPosition)
532
+ .map((column) => column.name);
533
+ const candidates = [];
534
+
535
+ if (primaryKeyColumns.length) {
536
+ candidates.push({
537
+ type: "primary_key",
538
+ columns: primaryKeyColumns,
539
+ });
540
+ }
541
+
542
+ const indexes = table.keyIndexes ?? table.indexes;
543
+
544
+ indexes
545
+ .filter((index) => index.unique && !index.partial && index.columns.length)
546
+ .filter((index) =>
547
+ index.columns.every((columnName) => {
548
+ const column = columnsByName.get(columnName);
549
+ return column?.notNull || column?.primaryKeyPosition > 0;
550
+ })
551
+ )
552
+ .sort((left, right) => left.columns.length - right.columns.length || left.name.localeCompare(right.name))
553
+ .forEach((index) => {
554
+ candidates.push({
555
+ type: "unique_index",
556
+ name: index.name,
557
+ columns: index.columns,
558
+ });
559
+ });
560
+
561
+ return candidates;
562
+ }
563
+
564
+ function arraysEqual(left, right) {
565
+ return (
566
+ left.length === right.length &&
567
+ left.every((value, index) => String(value) === String(right[index]))
568
+ );
569
+ }
570
+
571
+ function resolveSharedStableKey(baseTable, currentTable) {
572
+ const currentCandidates = getStableKeyCandidates(currentTable);
573
+
574
+ return (
575
+ getStableKeyCandidates(baseTable).find((baseCandidate) =>
576
+ currentCandidates.some((currentCandidate) =>
577
+ arraysEqual(baseCandidate.columns, currentCandidate.columns)
578
+ )
579
+ ) ?? null
580
+ );
581
+ }
582
+
583
+ function countTableRows(db, tableName) {
584
+ const row = db.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(tableName)}`).get();
585
+ return Number(row?.count ?? 0);
586
+ }
587
+
588
+ function rowIdentity(row, keyColumns) {
589
+ return keyColumns.map((column) => ({
590
+ column,
591
+ value: serializeSqliteValue(row[column]),
592
+ }));
593
+ }
594
+
595
+ function rowIdentityLabel(identity) {
596
+ return identity
597
+ .map((part) => `${part.column} = ${String(part.value?.value ?? part.value ?? "NULL")}`)
598
+ .join(", ");
599
+ }
600
+
601
+ function rowValues(row, columns) {
602
+ return Object.fromEntries(columns.map((column) => [column, serializeSqliteValue(row[column])]));
603
+ }
604
+
605
+ function buildRowKey(row, keyColumns) {
606
+ return JSON.stringify(keyColumns.map((column) => valueComparisonKey(row[column])));
607
+ }
608
+
609
+ function buildSelectSql(tableName, columns) {
610
+ return `SELECT ${columns.map((column) => quoteIdentifier(column)).join(", ")} FROM ${quoteIdentifier(
611
+ tableName
612
+ )}`;
613
+ }
614
+
615
+ function readRowsByKey(db, tableName, columns, keyColumns) {
616
+ const rows = new Map();
617
+ let duplicateKey = null;
618
+
619
+ for (const row of db.prepare(buildSelectSql(tableName, columns)).iterate()) {
620
+ const key = buildRowKey(row, keyColumns);
621
+
622
+ if (rows.has(key)) {
623
+ duplicateKey = rowIdentityLabel(rowIdentity(row, keyColumns));
624
+ break;
625
+ }
626
+
627
+ rows.set(key, row);
628
+ }
629
+
630
+ return {
631
+ rows,
632
+ duplicateKey,
633
+ };
634
+ }
635
+
636
+ function readSampleRows(db, tableName, columns, keyColumns, sampleLimit) {
637
+ if (!columns.length) {
638
+ return [];
639
+ }
640
+
641
+ const rows = db.prepare(`${buildSelectSql(tableName, columns)} LIMIT ?`).all(sampleLimit);
642
+
643
+ return rows.map((row) => {
644
+ const identity = keyColumns.length ? rowIdentity(row, keyColumns) : [];
645
+
646
+ return {
647
+ identity,
648
+ identityLabel: identity.length ? rowIdentityLabel(identity) : "",
649
+ values: rowValues(row, columns),
650
+ };
651
+ });
652
+ }
653
+
654
+ function buildSkippedDataTable(name, reason) {
655
+ return {
656
+ name,
657
+ status: "skipped",
658
+ statusLabel: "No stable key",
659
+ reason,
660
+ keyColumns: [],
661
+ added: null,
662
+ changed: null,
663
+ removed: null,
664
+ samples: {
665
+ added: [],
666
+ changed: [],
667
+ removed: [],
668
+ },
669
+ };
670
+ }
671
+
672
+ function compareTableRows({ baseDb, currentDb, tableName, baseTable, currentTable, sampleLimit }) {
673
+ const stableKey = resolveSharedStableKey(baseTable, currentTable);
674
+
675
+ if (!stableKey) {
676
+ return buildSkippedDataTable(
677
+ tableName,
678
+ "No primary key or non-null unique index exists in both versions."
679
+ );
680
+ }
681
+
682
+ const baseVisibleColumns = getVisibleColumnNames(baseTable);
683
+ const currentVisibleColumns = getVisibleColumnNames(currentTable);
684
+ const commonColumns = baseVisibleColumns.filter((column) => currentVisibleColumns.includes(column));
685
+ const compareColumns = commonColumns.filter((column) => !stableKey.columns.includes(column));
686
+ const selectColumns = [...new Set([...stableKey.columns, ...commonColumns])];
687
+ const baseRows = readRowsByKey(baseDb, tableName, selectColumns, stableKey.columns);
688
+ const currentRows = readRowsByKey(currentDb, tableName, selectColumns, stableKey.columns);
689
+
690
+ if (baseRows.duplicateKey || currentRows.duplicateKey) {
691
+ return buildSkippedDataTable(
692
+ tableName,
693
+ `Stable key contains duplicate values (${baseRows.duplicateKey || currentRows.duplicateKey}).`
694
+ );
695
+ }
696
+
697
+ const samples = {
698
+ added: [],
699
+ changed: [],
700
+ removed: [],
701
+ };
702
+ const seenCurrentKeys = new Set();
703
+ let added = 0;
704
+ let changed = 0;
705
+ let removed = 0;
706
+
707
+ baseRows.rows.forEach((baseRow, key) => {
708
+ const currentRow = currentRows.rows.get(key);
709
+
710
+ if (!currentRow) {
711
+ removed += 1;
712
+
713
+ if (samples.removed.length < sampleLimit) {
714
+ const identity = rowIdentity(baseRow, stableKey.columns);
715
+ samples.removed.push({
716
+ identity,
717
+ identityLabel: rowIdentityLabel(identity),
718
+ values: rowValues(baseRow, baseVisibleColumns),
719
+ });
720
+ }
721
+
722
+ return;
723
+ }
724
+
725
+ seenCurrentKeys.add(key);
726
+
727
+ const changedColumns = compareColumns
728
+ .filter((column) => !valuesEqual(baseRow[column], currentRow[column]))
729
+ .map((column) => ({
730
+ name: column,
731
+ backup: serializeSqliteValue(baseRow[column]),
732
+ current: serializeSqliteValue(currentRow[column]),
733
+ }));
734
+
735
+ if (changedColumns.length) {
736
+ changed += 1;
737
+
738
+ if (samples.changed.length < sampleLimit) {
739
+ const identity = rowIdentity(baseRow, stableKey.columns);
740
+ samples.changed.push({
741
+ identity,
742
+ identityLabel: rowIdentityLabel(identity),
743
+ columns: changedColumns,
744
+ });
745
+ }
746
+ }
747
+ });
748
+
749
+ currentRows.rows.forEach((currentRow, key) => {
750
+ if (seenCurrentKeys.has(key) || baseRows.rows.has(key)) {
751
+ return;
752
+ }
753
+
754
+ added += 1;
755
+
756
+ if (samples.added.length < sampleLimit) {
757
+ const identity = rowIdentity(currentRow, stableKey.columns);
758
+ samples.added.push({
759
+ identity,
760
+ identityLabel: rowIdentityLabel(identity),
761
+ values: rowValues(currentRow, currentVisibleColumns),
762
+ });
763
+ }
764
+ });
765
+
766
+ return {
767
+ name: tableName,
768
+ status: "comparable",
769
+ statusLabel: "Comparable",
770
+ reason: "",
771
+ keyColumns: stableKey.columns,
772
+ added,
773
+ changed,
774
+ removed,
775
+ samples,
776
+ };
777
+ }
778
+
779
+ function compareDataTables(baseDb, currentDb, baseSchema, currentSchema, sampleLimit) {
780
+ const tables = [];
781
+ const tableNames = [...new Set([...baseSchema.tables.keys(), ...currentSchema.tables.keys()])].sort();
782
+
783
+ tableNames.forEach((tableName) => {
784
+ const baseTable = baseSchema.tables.get(tableName);
785
+ const currentTable = currentSchema.tables.get(tableName);
786
+
787
+ try {
788
+ if (!baseTable && currentTable) {
789
+ const columns = getVisibleColumnNames(currentTable);
790
+ tables.push({
791
+ name: tableName,
792
+ status: "added_table",
793
+ statusLabel: "Added table",
794
+ reason: "",
795
+ keyColumns: [],
796
+ added: countTableRows(currentDb, tableName),
797
+ changed: 0,
798
+ removed: 0,
799
+ samples: {
800
+ added: readSampleRows(currentDb, tableName, columns, [], sampleLimit),
801
+ changed: [],
802
+ removed: [],
803
+ },
804
+ });
805
+ return;
806
+ }
807
+
808
+ if (baseTable && !currentTable) {
809
+ const columns = getVisibleColumnNames(baseTable);
810
+ tables.push({
811
+ name: tableName,
812
+ status: "removed_table",
813
+ statusLabel: "Removed table",
814
+ reason: "",
815
+ keyColumns: [],
816
+ added: 0,
817
+ changed: 0,
818
+ removed: countTableRows(baseDb, tableName),
819
+ samples: {
820
+ added: [],
821
+ changed: [],
822
+ removed: readSampleRows(baseDb, tableName, columns, [], sampleLimit),
823
+ },
824
+ });
825
+ return;
826
+ }
827
+
828
+ tables.push(
829
+ compareTableRows({
830
+ baseDb,
831
+ currentDb,
832
+ tableName,
833
+ baseTable,
834
+ currentTable,
835
+ sampleLimit,
836
+ })
837
+ );
838
+ } catch (error) {
839
+ tables.push(
840
+ buildSkippedDataTable(
841
+ tableName,
842
+ error?.message || "Table could not be compared safely."
843
+ )
844
+ );
845
+ }
846
+ });
847
+
848
+ return {
849
+ tables,
850
+ };
851
+ }
852
+
853
+ function countSchemaChanges(schema) {
854
+ return ["added", "changed", "removed"].reduce(
855
+ (sum, group) =>
856
+ sum +
857
+ schema[group].reduce(
858
+ (groupSum, entry) => groupSum + (Array.isArray(entry.changes) ? entry.changes.length : 1),
859
+ 0
860
+ ),
861
+ 0
862
+ );
863
+ }
864
+
865
+ function sumTableMetric(tables, metric) {
866
+ return tables.reduce((sum, table) => {
867
+ const value = Number(table[metric]);
868
+ return Number.isFinite(value) ? sum + value : sum;
869
+ }, 0);
870
+ }
871
+
872
+ function buildSummary(schema, data) {
873
+ return {
874
+ schemaChanges: countSchemaChanges(schema),
875
+ tablesAdded: schema.added.filter((entry) => entry.type === "table").length,
876
+ tablesRemoved: schema.removed.filter((entry) => entry.type === "table").length,
877
+ rowsAdded: sumTableMetric(data.tables, "added"),
878
+ rowsChanged: sumTableMetric(data.tables, "changed"),
879
+ rowsRemoved: sumTableMetric(data.tables, "removed"),
880
+ skippedTables: data.tables.filter((table) => table.status === "skipped").length,
881
+ };
882
+ }
883
+
884
+ function buildBackupDiff({ backupDb, currentDb, backup, currentConnection, comparedAt, sampleLimit }) {
885
+ const baseSchema = readSchemaSnapshot(backupDb);
886
+ const currentSchema = readSchemaSnapshot(currentDb);
887
+ const schema = buildSchemaDiff(baseSchema, currentSchema);
888
+ const data = compareDataTables(backupDb, currentDb, baseSchema, currentSchema, sampleLimit);
889
+
890
+ return {
891
+ backup: {
892
+ id: backup.id,
893
+ name: backup.name,
894
+ createdAt: backup.createdAt,
895
+ },
896
+ current: {
897
+ connectionId: currentConnection.id,
898
+ label: currentConnection.label,
899
+ },
900
+ comparedAt: comparedAt.toISOString(),
901
+ sampleLimit,
902
+ summary: buildSummary(schema, data),
903
+ schema,
904
+ data,
905
+ };
906
+ }
907
+
908
+ module.exports = {
909
+ DEFAULT_SAMPLE_LIMIT,
910
+ MAX_SAMPLE_LIMIT,
911
+ MIN_SAMPLE_LIMIT,
912
+ buildBackupDiff,
913
+ normalizeBackupDiffSampleLimit,
914
+ };