sqlite-hub 0.3.2 → 0.4.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 (63) hide show
  1. package/README.md +5 -5
  2. package/changelog.md +7 -0
  3. package/{js → frontend/js}/app.js +75 -3
  4. package/{js → frontend/js}/components/connectionCard.js +8 -9
  5. package/frontend/js/components/connectionLogo.js +33 -0
  6. package/{js → frontend/js}/components/emptyState.js +25 -11
  7. package/{js → frontend/js}/components/modal.js +57 -0
  8. package/{js → frontend/js}/components/sidebar.js +8 -3
  9. package/{js → frontend/js}/store.js +32 -0
  10. package/{js → frontend/js}/views/data.js +102 -9
  11. package/{js → frontend/js}/views/structure.js +10 -12
  12. package/{styles → frontend/styles}/structure-graph.css +5 -10
  13. package/package.json +2 -2
  14. package/{publish_brew.sh → scripts/publish_brew.sh} +2 -2
  15. package/{publish_npm.sh → scripts/publish_npm.sh} +2 -2
  16. package/server/data/db_logos/.gitkeep +0 -0
  17. package/server/routes/connections.js +2 -0
  18. package/server/server.js +7 -5
  19. package/server/services/sqlite/connectionManager.js +68 -33
  20. package/server/services/storage/appStateStore.js +159 -20
  21. package/server/utils/appPaths.js +42 -18
  22. /package/{assets → frontend/assets}/images/logo.webp +0 -0
  23. /package/{assets → frontend/assets}/images/logo_extrasmall.webp +0 -0
  24. /package/{assets → frontend/assets}/images/logo_raw.png +0 -0
  25. /package/{assets → frontend/assets}/images/logo_small.webp +0 -0
  26. /package/{assets → frontend/assets}/mockups/connections.png +0 -0
  27. /package/{assets → frontend/assets}/mockups/data.png +0 -0
  28. /package/{assets → frontend/assets}/mockups/data_edit.png +0 -0
  29. /package/{assets → frontend/assets}/mockups/graph_visualize.png +0 -0
  30. /package/{assets → frontend/assets}/mockups/home.png +0 -0
  31. /package/{assets → frontend/assets}/mockups/overview.png +0 -0
  32. /package/{assets → frontend/assets}/mockups/sql_editor.png +0 -0
  33. /package/{assets → frontend/assets}/mockups/structure.png +0 -0
  34. /package/{index.html → frontend/index.html} +0 -0
  35. /package/{js → frontend/js}/api.js +0 -0
  36. /package/{js → frontend/js}/components/actionBar.js +0 -0
  37. /package/{js → frontend/js}/components/appShell.js +0 -0
  38. /package/{js → frontend/js}/components/badges.js +0 -0
  39. /package/{js → frontend/js}/components/bottomTabs.js +0 -0
  40. /package/{js → frontend/js}/components/dataGrid.js +0 -0
  41. /package/{js → frontend/js}/components/metricCard.js +0 -0
  42. /package/{js → frontend/js}/components/pageHeader.js +0 -0
  43. /package/{js → frontend/js}/components/queryEditor.js +0 -0
  44. /package/{js → frontend/js}/components/queryResults.js +0 -0
  45. /package/{js → frontend/js}/components/rowEditorPanel.js +0 -0
  46. /package/{js → frontend/js}/components/statusBar.js +0 -0
  47. /package/{js → frontend/js}/components/structureGraph.js +0 -0
  48. /package/{js → frontend/js}/components/toast.js +0 -0
  49. /package/{js → frontend/js}/components/topNav.js +0 -0
  50. /package/{js → frontend/js}/lib/cytoscapeRuntime.js +0 -0
  51. /package/{js → frontend/js}/router.js +0 -0
  52. /package/{js → frontend/js}/utils/format.js +0 -0
  53. /package/{js → frontend/js}/views/connections.js +0 -0
  54. /package/{js → frontend/js}/views/editor.js +0 -0
  55. /package/{js → frontend/js}/views/landing.js +0 -0
  56. /package/{js → frontend/js}/views/overview.js +0 -0
  57. /package/{js → frontend/js}/views/settings.js +0 -0
  58. /package/{styles → frontend/styles}/base.css +0 -0
  59. /package/{styles → frontend/styles}/components.css +0 -0
  60. /package/{styles → frontend/styles}/layout.css +0 -0
  61. /package/{styles → frontend/styles}/tokens.css +0 -0
  62. /package/{styles → frontend/styles}/views.css +0 -0
  63. /package/{data → server/data}/.gitkeep +0 -0
@@ -1,6 +1,7 @@
1
1
  const fs = require("node:fs");
2
2
  const path = require("node:path");
3
3
  const Database = require("better-sqlite3");
4
+ const { ValidationError } = require("../../utils/errors");
4
5
 
5
6
  const DEFAULT_STATE = {
6
7
  recentConnections: [],
@@ -16,9 +17,24 @@ const DEFAULT_STATE = {
16
17
  },
17
18
  };
18
19
 
20
+ const CONNECTION_LOGO_DIRECTORY = "db_logos";
21
+ const MAX_CONNECTION_LOGO_SIZE_BYTES = 5 * 1024 * 1024;
22
+ const CONNECTION_LOGO_EXTENSION_BY_MIME_TYPE = {
23
+ "image/jpeg": "jpg",
24
+ "image/png": "png",
25
+ "image/webp": "webp",
26
+ };
27
+ const CONNECTION_LOGO_EXTENSION_BY_FILE_EXTENSION = {
28
+ ".jpeg": "jpg",
29
+ ".jpg": "jpg",
30
+ ".png": "png",
31
+ ".webp": "webp",
32
+ };
33
+
19
34
  class AppStateStore {
20
35
  constructor(filePath, options = {}) {
21
36
  this.filePath = filePath;
37
+ this.logoDirectory = path.join(path.dirname(this.filePath), CONNECTION_LOGO_DIRECTORY);
22
38
  this.legacyFilePath = options.legacyFilePath ?? null;
23
39
  this.legacyDatabasePaths = Array.isArray(options.legacyDatabasePaths)
24
40
  ? options.legacyDatabasePaths
@@ -26,6 +42,7 @@ class AppStateStore {
26
42
  this.isFreshDatabase = !fs.existsSync(filePath);
27
43
 
28
44
  fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
45
+ fs.mkdirSync(this.logoDirectory, { recursive: true });
29
46
 
30
47
  this.db = new Database(this.filePath);
31
48
  this.configureDatabase();
@@ -64,7 +81,8 @@ class AppStateStore {
64
81
  lastOpenedAt TEXT NOT NULL,
65
82
  lastModifiedAt TEXT,
66
83
  sizeBytes INTEGER,
67
- readOnly INTEGER NOT NULL DEFAULT 0
84
+ readOnly INTEGER NOT NULL DEFAULT 0,
85
+ logoPath TEXT
68
86
  );
69
87
 
70
88
  CREATE TABLE IF NOT EXISTS sql_history (
@@ -86,6 +104,17 @@ class AppStateStore {
86
104
  CREATE INDEX IF NOT EXISTS idx_sql_history_executed_at
87
105
  ON sql_history(executedAt DESC, id ASC);
88
106
  `);
107
+
108
+ const recentConnectionColumns = new Set(
109
+ this.db
110
+ .prepare("PRAGMA table_info(recent_connections)")
111
+ .all()
112
+ .map((column) => column.name)
113
+ );
114
+
115
+ if (!recentConnectionColumns.has("logoPath")) {
116
+ this.db.exec("ALTER TABLE recent_connections ADD COLUMN logoPath TEXT");
117
+ }
89
118
  }
90
119
 
91
120
  seedDefaultSettings() {
@@ -153,6 +182,14 @@ class AppStateStore {
153
182
  .all()
154
183
  .map((row) => row.name)
155
184
  );
185
+ const recentConnectionColumns = tables.has("recent_connections")
186
+ ? new Set(
187
+ legacyDb
188
+ .prepare("PRAGMA table_info(recent_connections)")
189
+ .all()
190
+ .map((column) => column.name)
191
+ )
192
+ : new Set();
156
193
 
157
194
  return {
158
195
  settings: tables.has("settings")
@@ -173,7 +210,8 @@ class AppStateStore {
173
210
  lastOpenedAt,
174
211
  lastModifiedAt,
175
212
  sizeBytes,
176
- readOnly
213
+ readOnly,
214
+ ${recentConnectionColumns.has("logoPath") ? "logoPath" : "NULL AS logoPath"}
177
215
  FROM recent_connections
178
216
  ORDER BY lastOpenedAt DESC, id ASC
179
217
  `)
@@ -223,16 +261,18 @@ class AppStateStore {
223
261
  lastOpenedAt,
224
262
  lastModifiedAt,
225
263
  sizeBytes,
226
- readOnly
264
+ readOnly,
265
+ logoPath
227
266
  )
228
- VALUES (?, ?, ?, ?, ?, ?, ?)
267
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
229
268
  ON CONFLICT(id) DO UPDATE SET
230
269
  label = excluded.label,
231
270
  path = excluded.path,
232
271
  lastOpenedAt = excluded.lastOpenedAt,
233
272
  lastModifiedAt = excluded.lastModifiedAt,
234
273
  sizeBytes = excluded.sizeBytes,
235
- readOnly = excluded.readOnly
274
+ readOnly = excluded.readOnly,
275
+ logoPath = excluded.logoPath
236
276
  `);
237
277
  const insertHistory = this.db.prepare(`
238
278
  INSERT INTO sql_history (
@@ -276,7 +316,8 @@ class AppStateStore {
276
316
  connection.lastOpenedAt ?? new Date().toISOString(),
277
317
  connection.lastModifiedAt ?? null,
278
318
  connection.sizeBytes ?? null,
279
- connection.readOnly ? 1 : 0
319
+ connection.readOnly ? 1 : 0,
320
+ this.normalizeLogoPath(connection.logoPath)
280
321
  );
281
322
  }
282
323
 
@@ -373,7 +414,7 @@ class AppStateStore {
373
414
 
374
415
  const staleRows = this.db
375
416
  .prepare(`
376
- SELECT id
417
+ SELECT id, logoPath
377
418
  FROM recent_connections
378
419
  ORDER BY lastOpenedAt DESC, id ASC
379
420
  LIMIT -1 OFFSET ?
@@ -393,6 +434,10 @@ class AppStateStore {
393
434
  if (activeConnectionId && staleRows.some((row) => row.id === activeConnectionId)) {
394
435
  this.setMetaValue("activeConnectionId", null);
395
436
  }
437
+
438
+ staleRows.forEach((row) => {
439
+ this.deleteConnectionLogo(row.logoPath);
440
+ });
396
441
  }
397
442
 
398
443
  trimSqlHistory() {
@@ -433,19 +478,19 @@ class AppStateStore {
433
478
  lastOpenedAt,
434
479
  lastModifiedAt,
435
480
  sizeBytes,
436
- readOnly
481
+ readOnly,
482
+ logoPath
437
483
  FROM recent_connections
438
484
  ORDER BY lastOpenedAt DESC, id ASC
439
485
  `)
440
486
  .all()
441
- .map((connection) => ({
442
- ...connection,
443
- readOnly: Boolean(connection.readOnly),
444
- }));
487
+ .map((connection) => this.decorateConnection(connection));
445
488
  }
446
489
 
447
490
  upsertRecentConnection(connection, options = {}) {
448
491
  const makeActive = options.makeActive !== false;
492
+ const existing = this.getRecentConnections().find((entry) => entry.id === connection.id);
493
+ const nextLogoPath = this.normalizeLogoPath(connection.logoPath);
449
494
 
450
495
  this.db.transaction(() => {
451
496
  this.db
@@ -457,16 +502,18 @@ class AppStateStore {
457
502
  lastOpenedAt,
458
503
  lastModifiedAt,
459
504
  sizeBytes,
460
- readOnly
505
+ readOnly,
506
+ logoPath
461
507
  )
462
- VALUES (?, ?, ?, ?, ?, ?, ?)
508
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
463
509
  ON CONFLICT(id) DO UPDATE SET
464
510
  label = excluded.label,
465
511
  path = excluded.path,
466
512
  lastOpenedAt = excluded.lastOpenedAt,
467
513
  lastModifiedAt = excluded.lastModifiedAt,
468
514
  sizeBytes = excluded.sizeBytes,
469
- readOnly = excluded.readOnly
515
+ readOnly = excluded.readOnly,
516
+ logoPath = excluded.logoPath
470
517
  `)
471
518
  .run(
472
519
  connection.id,
@@ -475,7 +522,8 @@ class AppStateStore {
475
522
  connection.lastOpenedAt,
476
523
  connection.lastModifiedAt ?? null,
477
524
  connection.sizeBytes ?? null,
478
- connection.readOnly ? 1 : 0
525
+ connection.readOnly ? 1 : 0,
526
+ nextLogoPath
479
527
  );
480
528
 
481
529
  if (makeActive) {
@@ -485,10 +533,16 @@ class AppStateStore {
485
533
  this.trimRecentConnections();
486
534
  })();
487
535
 
536
+ if (this.normalizeLogoPath(existing?.logoPath) !== nextLogoPath) {
537
+ this.deleteConnectionLogo(existing?.logoPath);
538
+ }
539
+
488
540
  return this.getRecentConnections();
489
541
  }
490
542
 
491
543
  removeRecentConnection(id) {
544
+ const existing = this.getRecentConnections().find((connection) => connection.id === id);
545
+
492
546
  this.db.transaction(() => {
493
547
  this.db.prepare("DELETE FROM recent_connections WHERE id = ?").run(id);
494
548
 
@@ -497,6 +551,8 @@ class AppStateStore {
497
551
  }
498
552
  })();
499
553
 
554
+ this.deleteConnectionLogo(existing?.logoPath);
555
+
500
556
  return this.getState();
501
557
  }
502
558
 
@@ -518,16 +574,18 @@ class AppStateStore {
518
574
  lastOpenedAt,
519
575
  lastModifiedAt,
520
576
  sizeBytes,
521
- readOnly
577
+ readOnly,
578
+ logoPath
522
579
  )
523
- VALUES (?, ?, ?, ?, ?, ?, ?)
580
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
524
581
  ON CONFLICT(id) DO UPDATE SET
525
582
  label = excluded.label,
526
583
  path = excluded.path,
527
584
  lastOpenedAt = excluded.lastOpenedAt,
528
585
  lastModifiedAt = excluded.lastModifiedAt,
529
586
  sizeBytes = excluded.sizeBytes,
530
- readOnly = excluded.readOnly
587
+ readOnly = excluded.readOnly,
588
+ logoPath = excluded.logoPath
531
589
  `)
532
590
  .run(
533
591
  nextConnection.id,
@@ -536,12 +594,93 @@ class AppStateStore {
536
594
  nextConnection.lastOpenedAt ?? existing.lastOpenedAt,
537
595
  nextConnection.lastModifiedAt ?? null,
538
596
  nextConnection.sizeBytes ?? null,
539
- nextConnection.readOnly ? 1 : 0
597
+ nextConnection.readOnly ? 1 : 0,
598
+ this.normalizeLogoPath(nextConnection.logoPath)
540
599
  );
541
600
 
601
+ if (this.normalizeLogoPath(nextConnection.logoPath) !== this.normalizeLogoPath(existing.logoPath)) {
602
+ this.deleteConnectionLogo(existing.logoPath);
603
+ }
604
+
542
605
  return this.getRecentConnections();
543
606
  }
544
607
 
608
+ getConnectionLogoUrl(logoPath) {
609
+ const normalizedLogoPath = this.normalizeLogoPath(logoPath);
610
+
611
+ if (!normalizedLogoPath) {
612
+ return null;
613
+ }
614
+
615
+ return `/${CONNECTION_LOGO_DIRECTORY}/${encodeURIComponent(normalizedLogoPath)}`;
616
+ }
617
+
618
+ saveConnectionLogo(connectionId, logoUpload = {}) {
619
+ const fileName = String(logoUpload.fileName ?? "").trim();
620
+ const mimeType = String(logoUpload.mimeType ?? "").trim().toLowerCase();
621
+ const base64 = String(logoUpload.base64 ?? "").trim();
622
+ const extension =
623
+ CONNECTION_LOGO_EXTENSION_BY_MIME_TYPE[mimeType] ??
624
+ CONNECTION_LOGO_EXTENSION_BY_FILE_EXTENSION[path.extname(fileName).toLowerCase()] ??
625
+ null;
626
+
627
+ if (!extension) {
628
+ throw new ValidationError("Connection logos must be one of: .png, .jpg, .jpeg, .webp");
629
+ }
630
+
631
+ if (!base64) {
632
+ throw new ValidationError("Connection logo upload is empty.");
633
+ }
634
+
635
+ const buffer = Buffer.from(base64, "base64");
636
+
637
+ if (!buffer.length) {
638
+ throw new ValidationError("Connection logo upload is empty.");
639
+ }
640
+
641
+ if (buffer.length > MAX_CONNECTION_LOGO_SIZE_BYTES) {
642
+ throw new ValidationError("Connection logo must be 5 MB or smaller.");
643
+ }
644
+
645
+ const safeConnectionId = String(connectionId ?? "connection").replace(/[^a-zA-Z0-9_-]/g, "_");
646
+ const storedFileName = `${safeConnectionId}-${Date.now()}.${extension}`;
647
+
648
+ fs.writeFileSync(path.join(this.logoDirectory, storedFileName), buffer);
649
+
650
+ return storedFileName;
651
+ }
652
+
653
+ deleteConnectionLogo(logoPath) {
654
+ const normalizedLogoPath = this.normalizeLogoPath(logoPath);
655
+
656
+ if (!normalizedLogoPath) {
657
+ return;
658
+ }
659
+
660
+ fs.rmSync(path.join(this.logoDirectory, normalizedLogoPath), { force: true });
661
+ }
662
+
663
+ decorateConnection(connection = {}) {
664
+ const logoPath = this.normalizeLogoPath(connection.logoPath);
665
+
666
+ return {
667
+ ...connection,
668
+ readOnly: Boolean(connection.readOnly),
669
+ logoPath,
670
+ logoUrl: this.getConnectionLogoUrl(logoPath),
671
+ };
672
+ }
673
+
674
+ normalizeLogoPath(logoPath) {
675
+ const trimmedLogoPath = String(logoPath ?? "").trim();
676
+
677
+ if (!trimmedLogoPath) {
678
+ return null;
679
+ }
680
+
681
+ return path.basename(trimmedLogoPath);
682
+ }
683
+
545
684
  setActiveConnectionId(id) {
546
685
  this.setMetaValue("activeConnectionId", id ?? null);
547
686
  return this.getActiveConnectionId();
@@ -6,6 +6,13 @@ const APP_NAME = "sqlite-hub";
6
6
  const APP_STATE_DB_FILENAME = "sqlite-hub-state.db";
7
7
  const LEGACY_STATE_FILENAME = "app-state.json";
8
8
 
9
+ function resolvePackagedDataDirectories(packageRoot) {
10
+ return [
11
+ path.resolve(packageRoot, "server", "data"),
12
+ path.resolve(packageRoot, "data"),
13
+ ].filter((candidatePath, index, candidates) => candidates.indexOf(candidatePath) === index);
14
+ }
15
+
9
16
  function resolveAppStateDirectory() {
10
17
  const homeDirectory = os.homedir();
11
18
 
@@ -28,7 +35,7 @@ function resolveAppStateDirectory() {
28
35
  }
29
36
 
30
37
  function resolvePackagedDataDirectory(packageRoot) {
31
- return path.resolve(packageRoot, "data");
38
+ return resolvePackagedDataDirectories(packageRoot)[0];
32
39
  }
33
40
 
34
41
  function resolvePackagedAppStateDbPath(packageRoot) {
@@ -36,7 +43,11 @@ function resolvePackagedAppStateDbPath(packageRoot) {
36
43
  }
37
44
 
38
45
  function resolvePackagedLegacyStatePath(packageRoot) {
39
- return path.join(resolvePackagedDataDirectory(packageRoot), LEGACY_STATE_FILENAME);
46
+ const candidates = resolvePackagedDataDirectories(packageRoot).map((directoryPath) =>
47
+ path.join(directoryPath, LEGACY_STATE_FILENAME)
48
+ );
49
+
50
+ return candidates.find((candidatePath) => fs.existsSync(candidatePath)) ?? candidates[0];
40
51
  }
41
52
 
42
53
  function resolveHomebrewCellarInfo(packageRoot) {
@@ -83,23 +94,34 @@ function collectHomebrewLegacyStateDbPaths(packageRoot) {
83
94
  return fs
84
95
  .readdirSync(cellarInfo.cellarRoot, { withFileTypes: true })
85
96
  .filter((entry) => entry.isDirectory() && entry.name !== cellarInfo.currentVersion)
86
- .map((entry) => {
87
- const candidatePath = path.join(
88
- cellarInfo.cellarRoot,
89
- entry.name,
90
- "libexec",
91
- "lib",
92
- "node_modules",
93
- APP_NAME,
94
- "data",
95
- APP_STATE_DB_FILENAME
96
- );
97
-
98
- return {
97
+ .flatMap((entry) =>
98
+ [
99
+ path.join(
100
+ cellarInfo.cellarRoot,
101
+ entry.name,
102
+ "libexec",
103
+ "lib",
104
+ "node_modules",
105
+ APP_NAME,
106
+ "server",
107
+ "data",
108
+ APP_STATE_DB_FILENAME
109
+ ),
110
+ path.join(
111
+ cellarInfo.cellarRoot,
112
+ entry.name,
113
+ "libexec",
114
+ "lib",
115
+ "node_modules",
116
+ APP_NAME,
117
+ "data",
118
+ APP_STATE_DB_FILENAME
119
+ ),
120
+ ].map((candidatePath) => ({
99
121
  path: candidatePath,
100
122
  mtimeMs: safeStatMtimeMs(candidatePath),
101
- };
102
- })
123
+ }))
124
+ )
103
125
  .filter((candidate) => candidate.mtimeMs >= 0)
104
126
  .sort((left, right) => right.mtimeMs - left.mtimeMs)
105
127
  .map((candidate) => candidate.path);
@@ -107,7 +129,9 @@ function collectHomebrewLegacyStateDbPaths(packageRoot) {
107
129
 
108
130
  function collectLegacyDatabasePaths(packageRoot) {
109
131
  return [
110
- resolvePackagedAppStateDbPath(packageRoot),
132
+ ...resolvePackagedDataDirectories(packageRoot).map((directoryPath) =>
133
+ path.join(directoryPath, APP_STATE_DB_FILENAME)
134
+ ),
111
135
  ...collectHomebrewLegacyStateDbPaths(packageRoot),
112
136
  ].filter((candidatePath, index, candidates) => candidates.indexOf(candidatePath) === index);
113
137
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes