sqlite-hub 0.3.2 → 0.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 (75) hide show
  1. package/README.md +5 -5
  2. package/changelog.md +14 -0
  3. package/{js → frontend/js}/api.js +99 -5
  4. package/{js → frontend/js}/app.js +162 -11
  5. package/{js → frontend/js}/components/connectionCard.js +8 -9
  6. package/frontend/js/components/connectionLogo.js +33 -0
  7. package/{js → frontend/js}/components/dataGrid.js +3 -3
  8. package/{js → frontend/js}/components/emptyState.js +25 -11
  9. package/{js → frontend/js}/components/modal.js +57 -0
  10. package/{js → frontend/js}/components/queryEditor.js +33 -55
  11. package/frontend/js/components/queryHistoryDetail.js +263 -0
  12. package/frontend/js/components/queryHistoryPanel.js +228 -0
  13. package/{js → frontend/js}/components/queryResults.js +32 -46
  14. package/{js → frontend/js}/components/rowEditorPanel.js +73 -14
  15. package/{js → frontend/js}/components/sidebar.js +8 -3
  16. package/{js → frontend/js}/store.js +577 -21
  17. package/{js → frontend/js}/utils/format.js +23 -0
  18. package/{js → frontend/js}/views/data.js +136 -10
  19. package/{js → frontend/js}/views/editor.js +34 -10
  20. package/{js → frontend/js}/views/overview.js +15 -0
  21. package/{js → frontend/js}/views/structure.js +10 -12
  22. package/{styles → frontend/styles}/components.css +106 -0
  23. package/{styles → frontend/styles}/structure-graph.css +5 -10
  24. package/package.json +2 -2
  25. package/{publish_brew.sh → scripts/publish_brew.sh} +2 -2
  26. package/{publish_npm.sh → scripts/publish_npm.sh} +2 -2
  27. package/server/data/db_logos/.gitkeep +0 -0
  28. package/server/routes/connections.js +2 -0
  29. package/server/routes/data.js +2 -0
  30. package/server/routes/export.js +4 -1
  31. package/server/routes/overview.js +12 -0
  32. package/server/routes/sql.js +163 -5
  33. package/server/server.js +8 -6
  34. package/server/services/sqlite/connectionManager.js +68 -33
  35. package/server/services/sqlite/dataBrowserService.js +4 -16
  36. package/server/services/sqlite/exportService.js +4 -16
  37. package/server/services/sqlite/overviewService.js +34 -0
  38. package/server/services/sqlite/sqlExecutor.js +83 -63
  39. package/server/services/sqlite/tableSort.js +63 -0
  40. package/server/services/storage/appStateStore.js +832 -20
  41. package/server/services/storage/queryHistoryUtils.js +169 -0
  42. package/server/utils/appPaths.js +42 -18
  43. package/{assets → frontend/assets}/images/logo.webp +0 -0
  44. package/{assets → frontend/assets}/images/logo_extrasmall.webp +0 -0
  45. package/{assets → frontend/assets}/images/logo_raw.png +0 -0
  46. package/{assets → frontend/assets}/images/logo_small.webp +0 -0
  47. package/{assets → frontend/assets}/mockups/connections.png +0 -0
  48. package/{assets → frontend/assets}/mockups/data.png +0 -0
  49. package/{assets → frontend/assets}/mockups/data_edit.png +0 -0
  50. package/{assets → frontend/assets}/mockups/graph_visualize.png +0 -0
  51. package/{assets → frontend/assets}/mockups/home.png +0 -0
  52. package/{assets → frontend/assets}/mockups/overview.png +0 -0
  53. package/{assets → frontend/assets}/mockups/sql_editor.png +0 -0
  54. package/{assets → frontend/assets}/mockups/structure.png +0 -0
  55. package/{index.html → frontend/index.html} +0 -0
  56. package/{js → frontend/js}/components/actionBar.js +0 -0
  57. package/{js → frontend/js}/components/appShell.js +0 -0
  58. package/{js → frontend/js}/components/badges.js +0 -0
  59. package/{js → frontend/js}/components/bottomTabs.js +1 -1
  60. /package/{js → frontend/js}/components/metricCard.js +0 -0
  61. /package/{js → frontend/js}/components/pageHeader.js +0 -0
  62. /package/{js → frontend/js}/components/statusBar.js +0 -0
  63. /package/{js → frontend/js}/components/structureGraph.js +0 -0
  64. /package/{js → frontend/js}/components/toast.js +0 -0
  65. /package/{js → frontend/js}/components/topNav.js +0 -0
  66. /package/{js → frontend/js}/lib/cytoscapeRuntime.js +0 -0
  67. /package/{js → frontend/js}/router.js +0 -0
  68. /package/{js → frontend/js}/views/connections.js +0 -0
  69. /package/{js → frontend/js}/views/landing.js +0 -0
  70. /package/{js → frontend/js}/views/settings.js +0 -0
  71. /package/{styles → frontend/styles}/base.css +0 -0
  72. /package/{styles → frontend/styles}/layout.css +0 -0
  73. /package/{styles → frontend/styles}/tokens.css +0 -0
  74. /package/{styles → frontend/styles}/views.css +0 -0
  75. /package/{data → server/data}/.gitkeep +0 -0
@@ -1,6 +1,15 @@
1
1
  const fs = require("node:fs");
2
2
  const path = require("node:path");
3
3
  const Database = require("better-sqlite3");
4
+ const { NotFoundError, ValidationError } = require("../../utils/errors");
5
+ const {
6
+ buildAutoTitle,
7
+ buildSqlPreview,
8
+ detectQueryType,
9
+ detectTables,
10
+ isDestructiveQuery,
11
+ normalizeSql,
12
+ } = require("./queryHistoryUtils");
4
13
 
5
14
  const DEFAULT_STATE = {
6
15
  recentConnections: [],
@@ -16,9 +25,25 @@ const DEFAULT_STATE = {
16
25
  },
17
26
  };
18
27
 
28
+ const CONNECTION_LOGO_DIRECTORY = "db_logos";
29
+ const MAX_CONNECTION_LOGO_SIZE_BYTES = 5 * 1024 * 1024;
30
+ const CONNECTION_LOGO_EXTENSION_BY_MIME_TYPE = {
31
+ "image/jpeg": "jpg",
32
+ "image/png": "png",
33
+ "image/webp": "webp",
34
+ };
35
+ const CONNECTION_LOGO_EXTENSION_BY_FILE_EXTENSION = {
36
+ ".jpeg": "jpg",
37
+ ".jpg": "jpg",
38
+ ".png": "png",
39
+ ".webp": "webp",
40
+ };
41
+ const QUERY_HISTORY_MIGRATION_KEY = "queryHistoryV1Migrated";
42
+
19
43
  class AppStateStore {
20
44
  constructor(filePath, options = {}) {
21
45
  this.filePath = filePath;
46
+ this.logoDirectory = path.join(path.dirname(this.filePath), CONNECTION_LOGO_DIRECTORY);
22
47
  this.legacyFilePath = options.legacyFilePath ?? null;
23
48
  this.legacyDatabasePaths = Array.isArray(options.legacyDatabasePaths)
24
49
  ? options.legacyDatabasePaths
@@ -26,6 +51,7 @@ class AppStateStore {
26
51
  this.isFreshDatabase = !fs.existsSync(filePath);
27
52
 
28
53
  fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
54
+ fs.mkdirSync(this.logoDirectory, { recursive: true });
29
55
 
30
56
  this.db = new Database(this.filePath);
31
57
  this.configureDatabase();
@@ -37,6 +63,8 @@ class AppStateStore {
37
63
  if (!importedLegacyDatabase && this.shouldImportLegacyState()) {
38
64
  this.tryImportLegacyState();
39
65
  }
66
+
67
+ this.migrateLegacySqlHistory();
40
68
  }
41
69
 
42
70
  configureDatabase() {
@@ -64,7 +92,8 @@ class AppStateStore {
64
92
  lastOpenedAt TEXT NOT NULL,
65
93
  lastModifiedAt TEXT,
66
94
  sizeBytes INTEGER,
67
- readOnly INTEGER NOT NULL DEFAULT 0
95
+ readOnly INTEGER NOT NULL DEFAULT 0,
96
+ logoPath TEXT
68
97
  );
69
98
 
70
99
  CREATE TABLE IF NOT EXISTS sql_history (
@@ -85,7 +114,74 @@ class AppStateStore {
85
114
 
86
115
  CREATE INDEX IF NOT EXISTS idx_sql_history_executed_at
87
116
  ON sql_history(executedAt DESC, id ASC);
117
+
118
+ CREATE TABLE IF NOT EXISTS query_history (
119
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
120
+ database_key TEXT NOT NULL,
121
+ normalized_sql TEXT NOT NULL,
122
+ raw_sql TEXT NOT NULL,
123
+ title TEXT,
124
+ notes TEXT,
125
+ query_type TEXT NOT NULL DEFAULT 'other',
126
+ tables_detected TEXT NOT NULL DEFAULT '[]',
127
+ is_favorite INTEGER NOT NULL DEFAULT 0,
128
+ is_saved INTEGER NOT NULL DEFAULT 0,
129
+ is_destructive INTEGER NOT NULL DEFAULT 0,
130
+ use_count INTEGER NOT NULL DEFAULT 1,
131
+ first_executed_at TEXT NOT NULL,
132
+ last_used_at TEXT NOT NULL
133
+ );
134
+
135
+ CREATE TABLE IF NOT EXISTS query_runs (
136
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
137
+ history_id INTEGER NOT NULL,
138
+ executed_at TEXT NOT NULL,
139
+ duration_ms INTEGER,
140
+ row_count INTEGER,
141
+ status TEXT NOT NULL CHECK(status IN ('success', 'error')),
142
+ error_message TEXT,
143
+ affected_rows INTEGER,
144
+ FOREIGN KEY (history_id) REFERENCES query_history(id) ON DELETE CASCADE
145
+ );
146
+
147
+ CREATE INDEX IF NOT EXISTS idx_query_history_database_key
148
+ ON query_history(database_key);
149
+
150
+ CREATE INDEX IF NOT EXISTS idx_query_history_last_used_at
151
+ ON query_history(last_used_at DESC);
152
+
153
+ CREATE INDEX IF NOT EXISTS idx_query_history_is_saved
154
+ ON query_history(is_saved);
155
+
156
+ CREATE INDEX IF NOT EXISTS idx_query_history_is_favorite
157
+ ON query_history(is_favorite);
158
+
159
+ CREATE INDEX IF NOT EXISTS idx_query_history_query_type
160
+ ON query_history(query_type);
161
+
162
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_query_history_database_normalized_sql
163
+ ON query_history(database_key, normalized_sql);
164
+
165
+ CREATE INDEX IF NOT EXISTS idx_query_runs_history_id
166
+ ON query_runs(history_id);
167
+
168
+ CREATE INDEX IF NOT EXISTS idx_query_runs_executed_at
169
+ ON query_runs(executed_at DESC);
170
+
171
+ CREATE INDEX IF NOT EXISTS idx_query_runs_status
172
+ ON query_runs(status);
88
173
  `);
174
+
175
+ const recentConnectionColumns = new Set(
176
+ this.db
177
+ .prepare("PRAGMA table_info(recent_connections)")
178
+ .all()
179
+ .map((column) => column.name)
180
+ );
181
+
182
+ if (!recentConnectionColumns.has("logoPath")) {
183
+ this.db.exec("ALTER TABLE recent_connections ADD COLUMN logoPath TEXT");
184
+ }
89
185
  }
90
186
 
91
187
  seedDefaultSettings() {
@@ -153,6 +249,14 @@ class AppStateStore {
153
249
  .all()
154
250
  .map((row) => row.name)
155
251
  );
252
+ const recentConnectionColumns = tables.has("recent_connections")
253
+ ? new Set(
254
+ legacyDb
255
+ .prepare("PRAGMA table_info(recent_connections)")
256
+ .all()
257
+ .map((column) => column.name)
258
+ )
259
+ : new Set();
156
260
 
157
261
  return {
158
262
  settings: tables.has("settings")
@@ -173,7 +277,8 @@ class AppStateStore {
173
277
  lastOpenedAt,
174
278
  lastModifiedAt,
175
279
  sizeBytes,
176
- readOnly
280
+ readOnly,
281
+ ${recentConnectionColumns.has("logoPath") ? "logoPath" : "NULL AS logoPath"}
177
282
  FROM recent_connections
178
283
  ORDER BY lastOpenedAt DESC, id ASC
179
284
  `)
@@ -223,16 +328,18 @@ class AppStateStore {
223
328
  lastOpenedAt,
224
329
  lastModifiedAt,
225
330
  sizeBytes,
226
- readOnly
331
+ readOnly,
332
+ logoPath
227
333
  )
228
- VALUES (?, ?, ?, ?, ?, ?, ?)
334
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
229
335
  ON CONFLICT(id) DO UPDATE SET
230
336
  label = excluded.label,
231
337
  path = excluded.path,
232
338
  lastOpenedAt = excluded.lastOpenedAt,
233
339
  lastModifiedAt = excluded.lastModifiedAt,
234
340
  sizeBytes = excluded.sizeBytes,
235
- readOnly = excluded.readOnly
341
+ readOnly = excluded.readOnly,
342
+ logoPath = excluded.logoPath
236
343
  `);
237
344
  const insertHistory = this.db.prepare(`
238
345
  INSERT INTO sql_history (
@@ -276,7 +383,8 @@ class AppStateStore {
276
383
  connection.lastOpenedAt ?? new Date().toISOString(),
277
384
  connection.lastModifiedAt ?? null,
278
385
  connection.sizeBytes ?? null,
279
- connection.readOnly ? 1 : 0
386
+ connection.readOnly ? 1 : 0,
387
+ this.normalizeLogoPath(connection.logoPath)
280
388
  );
281
389
  }
282
390
 
@@ -366,6 +474,612 @@ class AppStateStore {
366
474
  .run(key, String(value));
367
475
  }
368
476
 
477
+ normalizeQueryHistoryText(value) {
478
+ const text = String(value ?? "").trim();
479
+ return text ? text : null;
480
+ }
481
+
482
+ normalizeQueryHistoryInteger(value) {
483
+ if (value === null || value === undefined || value === "") {
484
+ return null;
485
+ }
486
+
487
+ const numericValue = Number(value);
488
+ return Number.isFinite(numericValue) ? Math.round(numericValue) : null;
489
+ }
490
+
491
+ parseTablesDetected(value) {
492
+ if (Array.isArray(value)) {
493
+ return Array.from(
494
+ new Set(
495
+ value
496
+ .map((entry) => String(entry ?? "").trim())
497
+ .filter(Boolean)
498
+ )
499
+ );
500
+ }
501
+
502
+ if (!value) {
503
+ return [];
504
+ }
505
+
506
+ try {
507
+ return this.parseTablesDetected(JSON.parse(value));
508
+ } catch {
509
+ return [];
510
+ }
511
+ }
512
+
513
+ decorateQueryRun(row = {}) {
514
+ return {
515
+ id: Number(row.id),
516
+ historyId: Number(row.history_id ?? row.historyId),
517
+ executedAt: row.executed_at ?? row.executedAt ?? null,
518
+ durationMs: this.normalizeQueryHistoryInteger(row.duration_ms ?? row.durationMs),
519
+ rowCount: this.normalizeQueryHistoryInteger(row.row_count ?? row.rowCount),
520
+ status: row.status ?? "success",
521
+ errorMessage: this.normalizeQueryHistoryText(row.error_message ?? row.errorMessage),
522
+ affectedRows: this.normalizeQueryHistoryInteger(row.affected_rows ?? row.affectedRows),
523
+ };
524
+ }
525
+
526
+ decorateQueryHistoryRow(row = {}) {
527
+ const tablesDetected = this.parseTablesDetected(row.tables_detected ?? row.tablesDetected);
528
+ const queryType = row.query_type ?? row.queryType ?? "other";
529
+ const title = this.normalizeQueryHistoryText(row.title);
530
+ const notes = this.normalizeQueryHistoryText(row.notes);
531
+ const lastRun =
532
+ row.last_run_id ?? row.lastRunId
533
+ ? this.decorateQueryRun({
534
+ id: row.last_run_id ?? row.lastRunId,
535
+ history_id: row.id,
536
+ executed_at: row.last_run_executed_at ?? row.lastRunExecutedAt,
537
+ duration_ms: row.last_run_duration_ms ?? row.lastRunDurationMs,
538
+ row_count: row.last_run_row_count ?? row.lastRunRowCount,
539
+ status: row.last_run_status ?? row.lastRunStatus,
540
+ error_message: row.last_run_error_message ?? row.lastRunErrorMessage,
541
+ affected_rows: row.last_run_affected_rows ?? row.lastRunAffectedRows,
542
+ })
543
+ : null;
544
+
545
+ return {
546
+ id: Number(row.id),
547
+ databaseKey: row.database_key ?? row.databaseKey,
548
+ normalizedSql: row.normalized_sql ?? row.normalizedSql,
549
+ rawSql: row.raw_sql ?? row.rawSql,
550
+ title,
551
+ notes,
552
+ queryType,
553
+ tablesDetected,
554
+ isFavorite: Boolean(row.is_favorite ?? row.isFavorite),
555
+ isSaved: Boolean(row.is_saved ?? row.isSaved),
556
+ isDestructive: Boolean(row.is_destructive ?? row.isDestructive),
557
+ useCount: Number(row.use_count ?? row.useCount ?? 0),
558
+ firstExecutedAt: row.first_executed_at ?? row.firstExecutedAt ?? null,
559
+ lastUsedAt: row.last_used_at ?? row.lastUsedAt ?? null,
560
+ displayTitle:
561
+ title ||
562
+ buildAutoTitle(row.raw_sql ?? row.rawSql, {
563
+ queryType,
564
+ tablesDetected,
565
+ }),
566
+ previewSql: buildSqlPreview(row.raw_sql ?? row.rawSql),
567
+ lastRun,
568
+ };
569
+ }
570
+
571
+ buildQueryHistoryFilters({
572
+ databaseKey,
573
+ search,
574
+ queryType,
575
+ onlySaved = false,
576
+ onlyFavorites = false,
577
+ latestStatus = null,
578
+ } = {}) {
579
+ const clauses = [];
580
+ const params = [];
581
+ const normalizedSearch = String(search ?? "").trim().toLowerCase();
582
+
583
+ if (databaseKey) {
584
+ clauses.push("q.database_key = ?");
585
+ params.push(databaseKey);
586
+ }
587
+
588
+ if (normalizedSearch) {
589
+ const searchPattern = `%${normalizedSearch}%`;
590
+ clauses.push(`
591
+ (
592
+ LOWER(COALESCE(q.title, '')) LIKE ?
593
+ OR LOWER(q.raw_sql) LIKE ?
594
+ OR LOWER(COALESCE(q.notes, '')) LIKE ?
595
+ OR LOWER(q.normalized_sql) LIKE ?
596
+ OR LOWER(q.tables_detected) LIKE ?
597
+ )
598
+ `);
599
+ params.push(
600
+ searchPattern,
601
+ searchPattern,
602
+ searchPattern,
603
+ searchPattern,
604
+ searchPattern
605
+ );
606
+ }
607
+
608
+ if (queryType) {
609
+ clauses.push("q.query_type = ?");
610
+ params.push(queryType);
611
+ }
612
+
613
+ if (onlySaved) {
614
+ clauses.push("q.is_saved = 1");
615
+ }
616
+
617
+ if (onlyFavorites) {
618
+ clauses.push("q.is_favorite = 1");
619
+ }
620
+
621
+ if (latestStatus) {
622
+ clauses.push("latest.status = ?");
623
+ params.push(latestStatus);
624
+ }
625
+
626
+ return {
627
+ whereSql: clauses.length ? `WHERE ${clauses.join(" AND ")}` : "",
628
+ params,
629
+ };
630
+ }
631
+
632
+ getQueryHistoryOrderBy({ onlySaved = false, onlyFavorites = false, latestStatus = null } = {}) {
633
+ if (onlySaved || onlyFavorites) {
634
+ return "ORDER BY q.is_favorite DESC, q.last_used_at DESC, q.id DESC";
635
+ }
636
+
637
+ if (latestStatus === "error") {
638
+ return "ORDER BY latest.executed_at DESC, q.id DESC";
639
+ }
640
+
641
+ return "ORDER BY q.last_used_at DESC, q.id DESC";
642
+ }
643
+
644
+ buildQueryHistoryCollection({
645
+ databaseKey,
646
+ limit = 30,
647
+ offset = 0,
648
+ search = "",
649
+ queryType = null,
650
+ onlySaved = false,
651
+ onlyFavorites = false,
652
+ latestStatus = null,
653
+ } = {}) {
654
+ const normalizedLimit = Math.max(1, Math.min(100, Number(limit) || 30));
655
+ const normalizedOffset = Math.max(0, Number(offset) || 0);
656
+
657
+ if (!databaseKey) {
658
+ return {
659
+ items: [],
660
+ total: 0,
661
+ limit: normalizedLimit,
662
+ offset: normalizedOffset,
663
+ hasMore: false,
664
+ };
665
+ }
666
+
667
+ const baseFromSql = `
668
+ FROM query_history q
669
+ LEFT JOIN query_runs latest
670
+ ON latest.id = (
671
+ SELECT runs.id
672
+ FROM query_runs runs
673
+ WHERE runs.history_id = q.id
674
+ ORDER BY runs.executed_at DESC, runs.id DESC
675
+ LIMIT 1
676
+ )
677
+ `;
678
+ const { whereSql, params } = this.buildQueryHistoryFilters({
679
+ databaseKey,
680
+ search,
681
+ queryType,
682
+ onlySaved,
683
+ onlyFavorites,
684
+ latestStatus,
685
+ });
686
+ const orderBySql = this.getQueryHistoryOrderBy({
687
+ onlySaved,
688
+ onlyFavorites,
689
+ latestStatus,
690
+ });
691
+ const rows = this.db
692
+ .prepare(`
693
+ SELECT
694
+ q.id,
695
+ q.database_key,
696
+ q.normalized_sql,
697
+ q.raw_sql,
698
+ q.title,
699
+ q.notes,
700
+ q.query_type,
701
+ q.tables_detected,
702
+ q.is_favorite,
703
+ q.is_saved,
704
+ q.is_destructive,
705
+ q.use_count,
706
+ q.first_executed_at,
707
+ q.last_used_at,
708
+ latest.id AS last_run_id,
709
+ latest.executed_at AS last_run_executed_at,
710
+ latest.duration_ms AS last_run_duration_ms,
711
+ latest.row_count AS last_run_row_count,
712
+ latest.status AS last_run_status,
713
+ latest.error_message AS last_run_error_message,
714
+ latest.affected_rows AS last_run_affected_rows
715
+ ${baseFromSql}
716
+ ${whereSql}
717
+ ${orderBySql}
718
+ LIMIT ?
719
+ OFFSET ?
720
+ `)
721
+ .all(...params, normalizedLimit, normalizedOffset)
722
+ .map((row) => this.decorateQueryHistoryRow(row));
723
+ const countRow = this.db
724
+ .prepare(`
725
+ SELECT COUNT(*) AS count
726
+ ${baseFromSql}
727
+ ${whereSql}
728
+ `)
729
+ .get(...params);
730
+ const total = Number(countRow?.count ?? 0);
731
+
732
+ return {
733
+ items: rows,
734
+ total,
735
+ limit: normalizedLimit,
736
+ offset: normalizedOffset,
737
+ hasMore: normalizedOffset + rows.length < total,
738
+ };
739
+ }
740
+
741
+ migrateLegacySqlHistory() {
742
+ if (this.getMetaValue(QUERY_HISTORY_MIGRATION_KEY) === "1") {
743
+ return;
744
+ }
745
+
746
+ const hasLegacyTable = Boolean(
747
+ this.db
748
+ .prepare(
749
+ "SELECT 1 AS exists_flag FROM sqlite_master WHERE type = 'table' AND name = 'sql_history'"
750
+ )
751
+ .get()
752
+ );
753
+
754
+ if (!hasLegacyTable) {
755
+ this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
756
+ return;
757
+ }
758
+
759
+ const legacyCount = Number(
760
+ this.db.prepare("SELECT COUNT(*) AS count FROM sql_history").get()?.count ?? 0
761
+ );
762
+
763
+ if (!legacyCount) {
764
+ this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
765
+ return;
766
+ }
767
+
768
+ const currentHistoryCount = Number(
769
+ this.db.prepare("SELECT COUNT(*) AS count FROM query_history").get()?.count ?? 0
770
+ );
771
+ const currentRunCount = Number(
772
+ this.db.prepare("SELECT COUNT(*) AS count FROM query_runs").get()?.count ?? 0
773
+ );
774
+
775
+ if (currentHistoryCount > 0 || currentRunCount > 0) {
776
+ this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
777
+ return;
778
+ }
779
+
780
+ const legacyRows = this.db
781
+ .prepare(`
782
+ SELECT
783
+ connectionId,
784
+ connectionLabel,
785
+ sql,
786
+ timingMs,
787
+ rowCount,
788
+ affectedRowCount,
789
+ executedAt
790
+ FROM sql_history
791
+ ORDER BY executedAt ASC, id ASC
792
+ `)
793
+ .all();
794
+
795
+ this.db.transaction(() => {
796
+ legacyRows.forEach((entry) => {
797
+ const databaseKey =
798
+ this.normalizeQueryHistoryText(entry.connectionId) ??
799
+ this.normalizeQueryHistoryText(entry.connectionLabel) ??
800
+ "legacy:default";
801
+ const rawSql = String(entry.sql ?? "");
802
+
803
+ if (!normalizeSql(rawSql)) {
804
+ return;
805
+ }
806
+
807
+ this.recordQueryExecutionInTransaction({
808
+ databaseKey,
809
+ rawSql,
810
+ status: "success",
811
+ durationMs: entry.timingMs,
812
+ rowCount: entry.rowCount,
813
+ affectedRows: entry.affectedRowCount,
814
+ executedAt: entry.executedAt ?? new Date().toISOString(),
815
+ });
816
+ });
817
+ })();
818
+
819
+ this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
820
+ }
821
+
822
+ recordQueryExecution(entry = {}) {
823
+ return this.db.transaction(() => this.recordQueryExecutionInTransaction(entry))();
824
+ }
825
+
826
+ recordQueryExecutionInTransaction({
827
+ databaseKey,
828
+ rawSql,
829
+ status,
830
+ durationMs = null,
831
+ rowCount = null,
832
+ affectedRows = null,
833
+ errorMessage = null,
834
+ executedAt = null,
835
+ } = {}) {
836
+ const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
837
+ const normalizedRawSql = String(rawSql ?? "");
838
+ const normalizedSql = normalizeSql(normalizedRawSql);
839
+
840
+ if (!normalizedDatabaseKey) {
841
+ throw new ValidationError("Query history requires a database key.");
842
+ }
843
+
844
+ if (!normalizedSql) {
845
+ throw new ValidationError("Query history requires executable SQL.");
846
+ }
847
+
848
+ if (!["success", "error"].includes(status)) {
849
+ throw new ValidationError(`Unsupported query run status: ${status}`);
850
+ }
851
+
852
+ const queryType = detectQueryType(normalizedRawSql);
853
+ const tablesDetected = detectTables(normalizedRawSql);
854
+ const timestamp = this.normalizeQueryHistoryText(executedAt) ?? new Date().toISOString();
855
+ const destructive = isDestructiveQuery(normalizedRawSql) ? 1 : 0;
856
+ const serializedTables = JSON.stringify(tablesDetected);
857
+ const existing = this.db
858
+ .prepare(
859
+ `
860
+ SELECT id
861
+ FROM query_history
862
+ WHERE database_key = ? AND normalized_sql = ?
863
+ `
864
+ )
865
+ .get(normalizedDatabaseKey, normalizedSql);
866
+ let historyId = Number(existing?.id ?? 0);
867
+
868
+ if (historyId) {
869
+ this.db
870
+ .prepare(`
871
+ UPDATE query_history
872
+ SET
873
+ raw_sql = ?,
874
+ query_type = ?,
875
+ tables_detected = ?,
876
+ is_destructive = ?,
877
+ use_count = use_count + 1,
878
+ last_used_at = ?
879
+ WHERE id = ?
880
+ `)
881
+ .run(
882
+ normalizedRawSql,
883
+ queryType,
884
+ serializedTables,
885
+ destructive,
886
+ timestamp,
887
+ historyId
888
+ );
889
+ } else {
890
+ const insertResult = this.db
891
+ .prepare(`
892
+ INSERT INTO query_history (
893
+ database_key,
894
+ normalized_sql,
895
+ raw_sql,
896
+ query_type,
897
+ tables_detected,
898
+ is_destructive,
899
+ use_count,
900
+ first_executed_at,
901
+ last_used_at
902
+ )
903
+ VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
904
+ `)
905
+ .run(
906
+ normalizedDatabaseKey,
907
+ normalizedSql,
908
+ normalizedRawSql,
909
+ queryType,
910
+ serializedTables,
911
+ destructive,
912
+ timestamp,
913
+ timestamp
914
+ );
915
+ historyId = Number(insertResult.lastInsertRowid);
916
+ }
917
+
918
+ this.db
919
+ .prepare(`
920
+ INSERT INTO query_runs (
921
+ history_id,
922
+ executed_at,
923
+ duration_ms,
924
+ row_count,
925
+ status,
926
+ error_message,
927
+ affected_rows
928
+ )
929
+ VALUES (?, ?, ?, ?, ?, ?, ?)
930
+ `)
931
+ .run(
932
+ historyId,
933
+ timestamp,
934
+ this.normalizeQueryHistoryInteger(durationMs),
935
+ this.normalizeQueryHistoryInteger(rowCount),
936
+ status,
937
+ this.normalizeQueryHistoryText(errorMessage),
938
+ this.normalizeQueryHistoryInteger(affectedRows)
939
+ );
940
+
941
+ return historyId;
942
+ }
943
+
944
+ getRecentQueries(options = {}) {
945
+ return this.buildQueryHistoryCollection(options);
946
+ }
947
+
948
+ getFailedQueries(options = {}) {
949
+ return this.buildQueryHistoryCollection({
950
+ ...options,
951
+ latestStatus: "error",
952
+ });
953
+ }
954
+
955
+ getQueryHistoryItemById(historyId) {
956
+ const row = this.db
957
+ .prepare(`
958
+ SELECT
959
+ q.id,
960
+ q.database_key,
961
+ q.normalized_sql,
962
+ q.raw_sql,
963
+ q.title,
964
+ q.notes,
965
+ q.query_type,
966
+ q.tables_detected,
967
+ q.is_favorite,
968
+ q.is_saved,
969
+ q.is_destructive,
970
+ q.use_count,
971
+ q.first_executed_at,
972
+ q.last_used_at,
973
+ latest.id AS last_run_id,
974
+ latest.executed_at AS last_run_executed_at,
975
+ latest.duration_ms AS last_run_duration_ms,
976
+ latest.row_count AS last_run_row_count,
977
+ latest.status AS last_run_status,
978
+ latest.error_message AS last_run_error_message,
979
+ latest.affected_rows AS last_run_affected_rows
980
+ FROM query_history q
981
+ LEFT JOIN query_runs latest
982
+ ON latest.id = (
983
+ SELECT runs.id
984
+ FROM query_runs runs
985
+ WHERE runs.history_id = q.id
986
+ ORDER BY runs.executed_at DESC, runs.id DESC
987
+ LIMIT 1
988
+ )
989
+ WHERE q.id = ?
990
+ `)
991
+ .get(Number(historyId));
992
+
993
+ if (!row) {
994
+ throw new NotFoundError(`Query history item not found: ${historyId}`);
995
+ }
996
+
997
+ return this.decorateQueryHistoryRow(row);
998
+ }
999
+
1000
+ getQueryRunsByHistoryId(historyId, limit = 8) {
1001
+ const normalizedLimit = Math.max(1, Math.min(50, Number(limit) || 8));
1002
+
1003
+ return this.db
1004
+ .prepare(`
1005
+ SELECT
1006
+ id,
1007
+ history_id,
1008
+ executed_at,
1009
+ duration_ms,
1010
+ row_count,
1011
+ status,
1012
+ error_message,
1013
+ affected_rows
1014
+ FROM query_runs
1015
+ WHERE history_id = ?
1016
+ ORDER BY executed_at DESC, id DESC
1017
+ LIMIT ?
1018
+ `)
1019
+ .all(Number(historyId), normalizedLimit)
1020
+ .map((row) => this.decorateQueryRun(row));
1021
+ }
1022
+
1023
+ updateQueryHistoryField(historyId, fieldName, value) {
1024
+ const result = this.db
1025
+ .prepare(`UPDATE query_history SET ${fieldName} = ? WHERE id = ?`)
1026
+ .run(value, Number(historyId));
1027
+
1028
+ if (!result.changes) {
1029
+ throw new NotFoundError(`Query history item not found: ${historyId}`);
1030
+ }
1031
+
1032
+ return this.getQueryHistoryItemById(historyId);
1033
+ }
1034
+
1035
+ toggleFavorite(historyId, nextValue) {
1036
+ return this.updateQueryHistoryField(historyId, "is_favorite", nextValue ? 1 : 0);
1037
+ }
1038
+
1039
+ toggleSaved(historyId, nextValue) {
1040
+ return this.updateQueryHistoryField(historyId, "is_saved", nextValue ? 1 : 0);
1041
+ }
1042
+
1043
+ renameQuery(historyId, title) {
1044
+ return this.updateQueryHistoryField(
1045
+ historyId,
1046
+ "title",
1047
+ this.normalizeQueryHistoryText(title)
1048
+ );
1049
+ }
1050
+
1051
+ updateQueryNotes(historyId, notes) {
1052
+ return this.updateQueryHistoryField(
1053
+ historyId,
1054
+ "notes",
1055
+ this.normalizeQueryHistoryText(notes)
1056
+ );
1057
+ }
1058
+
1059
+ deleteQueryHistoryItem(historyId) {
1060
+ const result = this.db
1061
+ .prepare("DELETE FROM query_history WHERE id = ?")
1062
+ .run(Number(historyId));
1063
+
1064
+ if (!result.changes) {
1065
+ throw new NotFoundError(`Query history item not found: ${historyId}`);
1066
+ }
1067
+
1068
+ return true;
1069
+ }
1070
+
1071
+ clearQueryHistoryForDatabase(databaseKey) {
1072
+ const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
1073
+
1074
+ if (!normalizedDatabaseKey) {
1075
+ return 0;
1076
+ }
1077
+
1078
+ return this.db
1079
+ .prepare("DELETE FROM query_history WHERE database_key = ?")
1080
+ .run(normalizedDatabaseKey).changes;
1081
+ }
1082
+
369
1083
  trimRecentConnections() {
370
1084
  const maxRecentConnections = Number(
371
1085
  this.getSettings().maxRecentConnections ?? DEFAULT_STATE.settings.maxRecentConnections
@@ -373,7 +1087,7 @@ class AppStateStore {
373
1087
 
374
1088
  const staleRows = this.db
375
1089
  .prepare(`
376
- SELECT id
1090
+ SELECT id, logoPath
377
1091
  FROM recent_connections
378
1092
  ORDER BY lastOpenedAt DESC, id ASC
379
1093
  LIMIT -1 OFFSET ?
@@ -393,6 +1107,10 @@ class AppStateStore {
393
1107
  if (activeConnectionId && staleRows.some((row) => row.id === activeConnectionId)) {
394
1108
  this.setMetaValue("activeConnectionId", null);
395
1109
  }
1110
+
1111
+ staleRows.forEach((row) => {
1112
+ this.deleteConnectionLogo(row.logoPath);
1113
+ });
396
1114
  }
397
1115
 
398
1116
  trimSqlHistory() {
@@ -433,19 +1151,19 @@ class AppStateStore {
433
1151
  lastOpenedAt,
434
1152
  lastModifiedAt,
435
1153
  sizeBytes,
436
- readOnly
1154
+ readOnly,
1155
+ logoPath
437
1156
  FROM recent_connections
438
1157
  ORDER BY lastOpenedAt DESC, id ASC
439
1158
  `)
440
1159
  .all()
441
- .map((connection) => ({
442
- ...connection,
443
- readOnly: Boolean(connection.readOnly),
444
- }));
1160
+ .map((connection) => this.decorateConnection(connection));
445
1161
  }
446
1162
 
447
1163
  upsertRecentConnection(connection, options = {}) {
448
1164
  const makeActive = options.makeActive !== false;
1165
+ const existing = this.getRecentConnections().find((entry) => entry.id === connection.id);
1166
+ const nextLogoPath = this.normalizeLogoPath(connection.logoPath);
449
1167
 
450
1168
  this.db.transaction(() => {
451
1169
  this.db
@@ -457,16 +1175,18 @@ class AppStateStore {
457
1175
  lastOpenedAt,
458
1176
  lastModifiedAt,
459
1177
  sizeBytes,
460
- readOnly
1178
+ readOnly,
1179
+ logoPath
461
1180
  )
462
- VALUES (?, ?, ?, ?, ?, ?, ?)
1181
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
463
1182
  ON CONFLICT(id) DO UPDATE SET
464
1183
  label = excluded.label,
465
1184
  path = excluded.path,
466
1185
  lastOpenedAt = excluded.lastOpenedAt,
467
1186
  lastModifiedAt = excluded.lastModifiedAt,
468
1187
  sizeBytes = excluded.sizeBytes,
469
- readOnly = excluded.readOnly
1188
+ readOnly = excluded.readOnly,
1189
+ logoPath = excluded.logoPath
470
1190
  `)
471
1191
  .run(
472
1192
  connection.id,
@@ -475,7 +1195,8 @@ class AppStateStore {
475
1195
  connection.lastOpenedAt,
476
1196
  connection.lastModifiedAt ?? null,
477
1197
  connection.sizeBytes ?? null,
478
- connection.readOnly ? 1 : 0
1198
+ connection.readOnly ? 1 : 0,
1199
+ nextLogoPath
479
1200
  );
480
1201
 
481
1202
  if (makeActive) {
@@ -485,10 +1206,16 @@ class AppStateStore {
485
1206
  this.trimRecentConnections();
486
1207
  })();
487
1208
 
1209
+ if (this.normalizeLogoPath(existing?.logoPath) !== nextLogoPath) {
1210
+ this.deleteConnectionLogo(existing?.logoPath);
1211
+ }
1212
+
488
1213
  return this.getRecentConnections();
489
1214
  }
490
1215
 
491
1216
  removeRecentConnection(id) {
1217
+ const existing = this.getRecentConnections().find((connection) => connection.id === id);
1218
+
492
1219
  this.db.transaction(() => {
493
1220
  this.db.prepare("DELETE FROM recent_connections WHERE id = ?").run(id);
494
1221
 
@@ -497,6 +1224,8 @@ class AppStateStore {
497
1224
  }
498
1225
  })();
499
1226
 
1227
+ this.deleteConnectionLogo(existing?.logoPath);
1228
+
500
1229
  return this.getState();
501
1230
  }
502
1231
 
@@ -518,16 +1247,18 @@ class AppStateStore {
518
1247
  lastOpenedAt,
519
1248
  lastModifiedAt,
520
1249
  sizeBytes,
521
- readOnly
1250
+ readOnly,
1251
+ logoPath
522
1252
  )
523
- VALUES (?, ?, ?, ?, ?, ?, ?)
1253
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
524
1254
  ON CONFLICT(id) DO UPDATE SET
525
1255
  label = excluded.label,
526
1256
  path = excluded.path,
527
1257
  lastOpenedAt = excluded.lastOpenedAt,
528
1258
  lastModifiedAt = excluded.lastModifiedAt,
529
1259
  sizeBytes = excluded.sizeBytes,
530
- readOnly = excluded.readOnly
1260
+ readOnly = excluded.readOnly,
1261
+ logoPath = excluded.logoPath
531
1262
  `)
532
1263
  .run(
533
1264
  nextConnection.id,
@@ -536,12 +1267,93 @@ class AppStateStore {
536
1267
  nextConnection.lastOpenedAt ?? existing.lastOpenedAt,
537
1268
  nextConnection.lastModifiedAt ?? null,
538
1269
  nextConnection.sizeBytes ?? null,
539
- nextConnection.readOnly ? 1 : 0
1270
+ nextConnection.readOnly ? 1 : 0,
1271
+ this.normalizeLogoPath(nextConnection.logoPath)
540
1272
  );
541
1273
 
1274
+ if (this.normalizeLogoPath(nextConnection.logoPath) !== this.normalizeLogoPath(existing.logoPath)) {
1275
+ this.deleteConnectionLogo(existing.logoPath);
1276
+ }
1277
+
542
1278
  return this.getRecentConnections();
543
1279
  }
544
1280
 
1281
+ getConnectionLogoUrl(logoPath) {
1282
+ const normalizedLogoPath = this.normalizeLogoPath(logoPath);
1283
+
1284
+ if (!normalizedLogoPath) {
1285
+ return null;
1286
+ }
1287
+
1288
+ return `/${CONNECTION_LOGO_DIRECTORY}/${encodeURIComponent(normalizedLogoPath)}`;
1289
+ }
1290
+
1291
+ saveConnectionLogo(connectionId, logoUpload = {}) {
1292
+ const fileName = String(logoUpload.fileName ?? "").trim();
1293
+ const mimeType = String(logoUpload.mimeType ?? "").trim().toLowerCase();
1294
+ const base64 = String(logoUpload.base64 ?? "").trim();
1295
+ const extension =
1296
+ CONNECTION_LOGO_EXTENSION_BY_MIME_TYPE[mimeType] ??
1297
+ CONNECTION_LOGO_EXTENSION_BY_FILE_EXTENSION[path.extname(fileName).toLowerCase()] ??
1298
+ null;
1299
+
1300
+ if (!extension) {
1301
+ throw new ValidationError("Connection logos must be one of: .png, .jpg, .jpeg, .webp");
1302
+ }
1303
+
1304
+ if (!base64) {
1305
+ throw new ValidationError("Connection logo upload is empty.");
1306
+ }
1307
+
1308
+ const buffer = Buffer.from(base64, "base64");
1309
+
1310
+ if (!buffer.length) {
1311
+ throw new ValidationError("Connection logo upload is empty.");
1312
+ }
1313
+
1314
+ if (buffer.length > MAX_CONNECTION_LOGO_SIZE_BYTES) {
1315
+ throw new ValidationError("Connection logo must be 5 MB or smaller.");
1316
+ }
1317
+
1318
+ const safeConnectionId = String(connectionId ?? "connection").replace(/[^a-zA-Z0-9_-]/g, "_");
1319
+ const storedFileName = `${safeConnectionId}-${Date.now()}.${extension}`;
1320
+
1321
+ fs.writeFileSync(path.join(this.logoDirectory, storedFileName), buffer);
1322
+
1323
+ return storedFileName;
1324
+ }
1325
+
1326
+ deleteConnectionLogo(logoPath) {
1327
+ const normalizedLogoPath = this.normalizeLogoPath(logoPath);
1328
+
1329
+ if (!normalizedLogoPath) {
1330
+ return;
1331
+ }
1332
+
1333
+ fs.rmSync(path.join(this.logoDirectory, normalizedLogoPath), { force: true });
1334
+ }
1335
+
1336
+ decorateConnection(connection = {}) {
1337
+ const logoPath = this.normalizeLogoPath(connection.logoPath);
1338
+
1339
+ return {
1340
+ ...connection,
1341
+ readOnly: Boolean(connection.readOnly),
1342
+ logoPath,
1343
+ logoUrl: this.getConnectionLogoUrl(logoPath),
1344
+ };
1345
+ }
1346
+
1347
+ normalizeLogoPath(logoPath) {
1348
+ const trimmedLogoPath = String(logoPath ?? "").trim();
1349
+
1350
+ if (!trimmedLogoPath) {
1351
+ return null;
1352
+ }
1353
+
1354
+ return path.basename(trimmedLogoPath);
1355
+ }
1356
+
545
1357
  setActiveConnectionId(id) {
546
1358
  this.setMetaValue("activeConnectionId", id ?? null);
547
1359
  return this.getActiveConnectionId();