sqlite-hub 0.1.3

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 (76) hide show
  1. package/.npmingnore +4 -0
  2. package/README.md +46 -0
  3. package/assets/images/logo.webp +0 -0
  4. package/assets/images/logo_extrasmall.webp +0 -0
  5. package/assets/images/logo_raw.png +0 -0
  6. package/assets/images/logo_small.webp +0 -0
  7. package/assets/mockups/connections.png +0 -0
  8. package/assets/mockups/data.png +0 -0
  9. package/assets/mockups/data_edit.png +0 -0
  10. package/assets/mockups/home.png +0 -0
  11. package/assets/mockups/overview.png +0 -0
  12. package/assets/mockups/sql_editor.png +0 -0
  13. package/assets/mockups/structure.png +0 -0
  14. package/bin/sqlite-hub.js +116 -0
  15. package/changelog.md +3 -0
  16. package/data/.gitkeep +0 -0
  17. package/index.html +100 -0
  18. package/js/api.js +193 -0
  19. package/js/app.js +520 -0
  20. package/js/components/actionBar.js +8 -0
  21. package/js/components/appShell.js +17 -0
  22. package/js/components/badges.js +5 -0
  23. package/js/components/bottomTabs.js +37 -0
  24. package/js/components/connectionCard.js +106 -0
  25. package/js/components/dataGrid.js +47 -0
  26. package/js/components/emptyState.js +159 -0
  27. package/js/components/metricCard.js +32 -0
  28. package/js/components/modal.js +317 -0
  29. package/js/components/pageHeader.js +33 -0
  30. package/js/components/queryEditor.js +121 -0
  31. package/js/components/queryResults.js +107 -0
  32. package/js/components/rowEditorPanel.js +164 -0
  33. package/js/components/sidebar.js +57 -0
  34. package/js/components/statusBar.js +39 -0
  35. package/js/components/toast.js +39 -0
  36. package/js/components/topNav.js +27 -0
  37. package/js/router.js +66 -0
  38. package/js/store.js +1092 -0
  39. package/js/utils/format.js +179 -0
  40. package/js/views/connections.js +133 -0
  41. package/js/views/data.js +400 -0
  42. package/js/views/editor.js +259 -0
  43. package/js/views/landing.js +11 -0
  44. package/js/views/overview.js +220 -0
  45. package/js/views/settings.js +109 -0
  46. package/js/views/structure.js +242 -0
  47. package/package.json +18 -0
  48. package/publish_brew.sh +444 -0
  49. package/publish_npm.sh +241 -0
  50. package/server/routes/connections.js +146 -0
  51. package/server/routes/data.js +59 -0
  52. package/server/routes/export.js +25 -0
  53. package/server/routes/overview.js +39 -0
  54. package/server/routes/settings.js +50 -0
  55. package/server/routes/sql.js +50 -0
  56. package/server/routes/structure.js +38 -0
  57. package/server/server.js +136 -0
  58. package/server/services/sqlite/connectionManager.js +306 -0
  59. package/server/services/sqlite/dataBrowserService.js +255 -0
  60. package/server/services/sqlite/exportService.js +34 -0
  61. package/server/services/sqlite/importService.js +111 -0
  62. package/server/services/sqlite/introspection.js +302 -0
  63. package/server/services/sqlite/overviewService.js +109 -0
  64. package/server/services/sqlite/sqlExecutor.js +434 -0
  65. package/server/services/sqlite/structureService.js +60 -0
  66. package/server/services/storage/appStateStore.js +530 -0
  67. package/server/utils/csv.js +34 -0
  68. package/server/utils/errors.js +175 -0
  69. package/server/utils/fileValidation.js +135 -0
  70. package/server/utils/identifier.js +38 -0
  71. package/server/utils/sqliteTypes.js +112 -0
  72. package/styles/base.css +176 -0
  73. package/styles/components.css +323 -0
  74. package/styles/layout.css +101 -0
  75. package/styles/tokens.css +49 -0
  76. package/styles/views.css +84 -0
@@ -0,0 +1,530 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const Database = require("better-sqlite3");
4
+
5
+ const DEFAULT_STATE = {
6
+ recentConnections: [],
7
+ activeConnectionId: null,
8
+ sqlHistory: [],
9
+ settings: {
10
+ defaultPageSize: 50,
11
+ maxPageSize: 200,
12
+ maxRecentConnections: 12,
13
+ maxSqlHistory: 100,
14
+ busyTimeoutMs: 5000,
15
+ csvDelimiter: ",",
16
+ },
17
+ };
18
+
19
+ class AppStateStore {
20
+ constructor(filePath, options = {}) {
21
+ this.filePath = filePath;
22
+ this.legacyFilePath = options.legacyFilePath ?? null;
23
+ this.isFreshDatabase = !fs.existsSync(filePath);
24
+
25
+ fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
26
+
27
+ this.db = new Database(this.filePath);
28
+ this.configureDatabase();
29
+ this.ensureSchema();
30
+ this.seedDefaultSettings();
31
+
32
+ if (this.shouldImportLegacyState()) {
33
+ this.importLegacyState();
34
+ }
35
+ }
36
+
37
+ configureDatabase() {
38
+ this.db.pragma("journal_mode = WAL");
39
+ this.db.pragma("synchronous = NORMAL");
40
+ this.db.pragma("foreign_keys = ON");
41
+ }
42
+
43
+ ensureSchema() {
44
+ this.db.exec(`
45
+ CREATE TABLE IF NOT EXISTS app_meta (
46
+ key TEXT PRIMARY KEY,
47
+ value TEXT
48
+ );
49
+
50
+ CREATE TABLE IF NOT EXISTS settings (
51
+ key TEXT PRIMARY KEY,
52
+ value TEXT NOT NULL
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS recent_connections (
56
+ id TEXT PRIMARY KEY,
57
+ label TEXT NOT NULL,
58
+ path TEXT NOT NULL,
59
+ lastOpenedAt TEXT NOT NULL,
60
+ lastModifiedAt TEXT,
61
+ sizeBytes INTEGER,
62
+ readOnly INTEGER NOT NULL DEFAULT 0
63
+ );
64
+
65
+ CREATE TABLE IF NOT EXISTS sql_history (
66
+ id TEXT PRIMARY KEY,
67
+ connectionId TEXT,
68
+ connectionLabel TEXT,
69
+ sql TEXT NOT NULL,
70
+ statementCount INTEGER NOT NULL DEFAULT 0,
71
+ resultKind TEXT,
72
+ affectedRowCount INTEGER NOT NULL DEFAULT 0,
73
+ rowCount INTEGER NOT NULL DEFAULT 0,
74
+ timingMs INTEGER NOT NULL DEFAULT 0,
75
+ executedAt TEXT NOT NULL
76
+ );
77
+
78
+ CREATE INDEX IF NOT EXISTS idx_recent_connections_last_opened
79
+ ON recent_connections(lastOpenedAt DESC, id ASC);
80
+
81
+ CREATE INDEX IF NOT EXISTS idx_sql_history_executed_at
82
+ ON sql_history(executedAt DESC, id ASC);
83
+ `);
84
+ }
85
+
86
+ seedDefaultSettings() {
87
+ const insertSetting = this.db.prepare(`
88
+ INSERT INTO settings (key, value)
89
+ VALUES (?, ?)
90
+ ON CONFLICT(key) DO NOTHING
91
+ `);
92
+
93
+ for (const [key, value] of Object.entries(DEFAULT_STATE.settings)) {
94
+ insertSetting.run(key, JSON.stringify(value));
95
+ }
96
+ }
97
+
98
+ shouldImportLegacyState() {
99
+ if (!this.isFreshDatabase) {
100
+ return false;
101
+ }
102
+
103
+ if (!this.legacyFilePath || this.legacyFilePath === this.filePath) {
104
+ return false;
105
+ }
106
+
107
+ return fs.existsSync(this.legacyFilePath);
108
+ }
109
+
110
+ readLegacyState() {
111
+ const raw = fs.readFileSync(this.legacyFilePath, "utf8");
112
+ const parsed = JSON.parse(raw);
113
+
114
+ return {
115
+ ...DEFAULT_STATE,
116
+ ...parsed,
117
+ settings: {
118
+ ...DEFAULT_STATE.settings,
119
+ ...(parsed.settings ?? {}),
120
+ },
121
+ };
122
+ }
123
+
124
+ importLegacyState() {
125
+ const legacyState = this.readLegacyState();
126
+ const insertSetting = this.db.prepare(`
127
+ INSERT INTO settings (key, value)
128
+ VALUES (?, ?)
129
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
130
+ `);
131
+ const insertConnection = this.db.prepare(`
132
+ INSERT INTO recent_connections (
133
+ id,
134
+ label,
135
+ path,
136
+ lastOpenedAt,
137
+ lastModifiedAt,
138
+ sizeBytes,
139
+ readOnly
140
+ )
141
+ VALUES (?, ?, ?, ?, ?, ?, ?)
142
+ ON CONFLICT(id) DO UPDATE SET
143
+ label = excluded.label,
144
+ path = excluded.path,
145
+ lastOpenedAt = excluded.lastOpenedAt,
146
+ lastModifiedAt = excluded.lastModifiedAt,
147
+ sizeBytes = excluded.sizeBytes,
148
+ readOnly = excluded.readOnly
149
+ `);
150
+ const insertHistory = this.db.prepare(`
151
+ INSERT INTO sql_history (
152
+ id,
153
+ connectionId,
154
+ connectionLabel,
155
+ sql,
156
+ statementCount,
157
+ resultKind,
158
+ affectedRowCount,
159
+ rowCount,
160
+ timingMs,
161
+ executedAt
162
+ )
163
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
164
+ ON CONFLICT(id) DO UPDATE SET
165
+ connectionId = excluded.connectionId,
166
+ connectionLabel = excluded.connectionLabel,
167
+ sql = excluded.sql,
168
+ statementCount = excluded.statementCount,
169
+ resultKind = excluded.resultKind,
170
+ affectedRowCount = excluded.affectedRowCount,
171
+ rowCount = excluded.rowCount,
172
+ timingMs = excluded.timingMs,
173
+ executedAt = excluded.executedAt
174
+ `);
175
+
176
+ this.db.transaction(() => {
177
+ for (const [key, value] of Object.entries(legacyState.settings ?? {})) {
178
+ insertSetting.run(key, JSON.stringify(value));
179
+ }
180
+
181
+ for (const connection of legacyState.recentConnections ?? []) {
182
+ insertConnection.run(
183
+ connection.id,
184
+ connection.label ?? path.basename(connection.path ?? connection.id),
185
+ connection.path ?? "",
186
+ connection.lastOpenedAt ?? new Date().toISOString(),
187
+ connection.lastModifiedAt ?? null,
188
+ connection.sizeBytes ?? null,
189
+ connection.readOnly ? 1 : 0
190
+ );
191
+ }
192
+
193
+ for (const entry of legacyState.sqlHistory ?? []) {
194
+ insertHistory.run(
195
+ entry.id,
196
+ entry.connectionId ?? null,
197
+ entry.connectionLabel ?? null,
198
+ entry.sql ?? "",
199
+ Number(entry.statementCount ?? 0),
200
+ entry.resultKind ?? null,
201
+ Number(entry.affectedRowCount ?? 0),
202
+ Number(entry.rowCount ?? 0),
203
+ Number(entry.timingMs ?? 0),
204
+ entry.executedAt ?? new Date().toISOString()
205
+ );
206
+ }
207
+
208
+ this.setMetaValue("activeConnectionId", legacyState.activeConnectionId ?? null);
209
+ this.setMetaValue(
210
+ "legacyImportSource",
211
+ path.relative(path.dirname(this.filePath), this.legacyFilePath)
212
+ );
213
+ })();
214
+ }
215
+
216
+ parseStoredValue(value) {
217
+ try {
218
+ return JSON.parse(value);
219
+ } catch {
220
+ return value;
221
+ }
222
+ }
223
+
224
+ getMetaValue(key) {
225
+ const row = this.db
226
+ .prepare("SELECT value FROM app_meta WHERE key = ?")
227
+ .get(key);
228
+
229
+ return row ? row.value : null;
230
+ }
231
+
232
+ setMetaValue(key, value) {
233
+ if (value === null || value === undefined || value === "") {
234
+ this.db.prepare("DELETE FROM app_meta WHERE key = ?").run(key);
235
+ return;
236
+ }
237
+
238
+ this.db
239
+ .prepare(`
240
+ INSERT INTO app_meta (key, value)
241
+ VALUES (?, ?)
242
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
243
+ `)
244
+ .run(key, String(value));
245
+ }
246
+
247
+ trimRecentConnections() {
248
+ const maxRecentConnections = Number(
249
+ this.getSettings().maxRecentConnections ?? DEFAULT_STATE.settings.maxRecentConnections
250
+ );
251
+
252
+ const staleRows = this.db
253
+ .prepare(`
254
+ SELECT id
255
+ FROM recent_connections
256
+ ORDER BY lastOpenedAt DESC, id ASC
257
+ LIMIT -1 OFFSET ?
258
+ `)
259
+ .all(maxRecentConnections);
260
+
261
+ if (!staleRows.length) {
262
+ return;
263
+ }
264
+
265
+ const activeConnectionId = this.getActiveConnectionId();
266
+
267
+ for (const row of staleRows) {
268
+ this.db.prepare("DELETE FROM recent_connections WHERE id = ?").run(row.id);
269
+ }
270
+
271
+ if (activeConnectionId && staleRows.some((row) => row.id === activeConnectionId)) {
272
+ this.setMetaValue("activeConnectionId", null);
273
+ }
274
+ }
275
+
276
+ trimSqlHistory() {
277
+ const maxSqlHistory = Number(
278
+ this.getSettings().maxSqlHistory ?? DEFAULT_STATE.settings.maxSqlHistory
279
+ );
280
+
281
+ const staleRows = this.db
282
+ .prepare(`
283
+ SELECT id
284
+ FROM sql_history
285
+ ORDER BY executedAt DESC, id ASC
286
+ LIMIT -1 OFFSET ?
287
+ `)
288
+ .all(maxSqlHistory);
289
+
290
+ for (const row of staleRows) {
291
+ this.db.prepare("DELETE FROM sql_history WHERE id = ?").run(row.id);
292
+ }
293
+ }
294
+
295
+ getState() {
296
+ return structuredClone({
297
+ recentConnections: this.getRecentConnections(),
298
+ activeConnectionId: this.getActiveConnectionId(),
299
+ sqlHistory: this.getSqlHistory(),
300
+ settings: this.getSettings(),
301
+ });
302
+ }
303
+
304
+ getRecentConnections() {
305
+ return this.db
306
+ .prepare(`
307
+ SELECT
308
+ id,
309
+ label,
310
+ path,
311
+ lastOpenedAt,
312
+ lastModifiedAt,
313
+ sizeBytes,
314
+ readOnly
315
+ FROM recent_connections
316
+ ORDER BY lastOpenedAt DESC, id ASC
317
+ `)
318
+ .all()
319
+ .map((connection) => ({
320
+ ...connection,
321
+ readOnly: Boolean(connection.readOnly),
322
+ }));
323
+ }
324
+
325
+ upsertRecentConnection(connection) {
326
+ this.db.transaction(() => {
327
+ this.db
328
+ .prepare(`
329
+ INSERT INTO recent_connections (
330
+ id,
331
+ label,
332
+ path,
333
+ lastOpenedAt,
334
+ lastModifiedAt,
335
+ sizeBytes,
336
+ readOnly
337
+ )
338
+ VALUES (?, ?, ?, ?, ?, ?, ?)
339
+ ON CONFLICT(id) DO UPDATE SET
340
+ label = excluded.label,
341
+ path = excluded.path,
342
+ lastOpenedAt = excluded.lastOpenedAt,
343
+ lastModifiedAt = excluded.lastModifiedAt,
344
+ sizeBytes = excluded.sizeBytes,
345
+ readOnly = excluded.readOnly
346
+ `)
347
+ .run(
348
+ connection.id,
349
+ connection.label,
350
+ connection.path,
351
+ connection.lastOpenedAt,
352
+ connection.lastModifiedAt ?? null,
353
+ connection.sizeBytes ?? null,
354
+ connection.readOnly ? 1 : 0
355
+ );
356
+
357
+ this.setMetaValue("activeConnectionId", connection.id);
358
+ this.trimRecentConnections();
359
+ })();
360
+
361
+ return this.getRecentConnections();
362
+ }
363
+
364
+ removeRecentConnection(id) {
365
+ this.db.transaction(() => {
366
+ this.db.prepare("DELETE FROM recent_connections WHERE id = ?").run(id);
367
+
368
+ if (this.getActiveConnectionId() === id) {
369
+ this.setMetaValue("activeConnectionId", null);
370
+ }
371
+ })();
372
+
373
+ return this.getState();
374
+ }
375
+
376
+ updateRecentConnection(id, updater) {
377
+ const existing = this.getRecentConnections().find((connection) => connection.id === id);
378
+
379
+ if (!existing) {
380
+ return this.getRecentConnections();
381
+ }
382
+
383
+ const nextConnection = updater(structuredClone(existing)) ?? existing;
384
+
385
+ this.db
386
+ .prepare(`
387
+ INSERT INTO recent_connections (
388
+ id,
389
+ label,
390
+ path,
391
+ lastOpenedAt,
392
+ lastModifiedAt,
393
+ sizeBytes,
394
+ readOnly
395
+ )
396
+ VALUES (?, ?, ?, ?, ?, ?, ?)
397
+ ON CONFLICT(id) DO UPDATE SET
398
+ label = excluded.label,
399
+ path = excluded.path,
400
+ lastOpenedAt = excluded.lastOpenedAt,
401
+ lastModifiedAt = excluded.lastModifiedAt,
402
+ sizeBytes = excluded.sizeBytes,
403
+ readOnly = excluded.readOnly
404
+ `)
405
+ .run(
406
+ nextConnection.id,
407
+ nextConnection.label,
408
+ nextConnection.path,
409
+ nextConnection.lastOpenedAt ?? existing.lastOpenedAt,
410
+ nextConnection.lastModifiedAt ?? null,
411
+ nextConnection.sizeBytes ?? null,
412
+ nextConnection.readOnly ? 1 : 0
413
+ );
414
+
415
+ return this.getRecentConnections();
416
+ }
417
+
418
+ setActiveConnectionId(id) {
419
+ this.setMetaValue("activeConnectionId", id ?? null);
420
+ return this.getActiveConnectionId();
421
+ }
422
+
423
+ getActiveConnectionId() {
424
+ return this.getMetaValue("activeConnectionId");
425
+ }
426
+
427
+ addSqlHistory(entry) {
428
+ this.db.transaction(() => {
429
+ this.db
430
+ .prepare(`
431
+ INSERT INTO sql_history (
432
+ id,
433
+ connectionId,
434
+ connectionLabel,
435
+ sql,
436
+ statementCount,
437
+ resultKind,
438
+ affectedRowCount,
439
+ rowCount,
440
+ timingMs,
441
+ executedAt
442
+ )
443
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
444
+ `)
445
+ .run(
446
+ entry.id,
447
+ entry.connectionId ?? null,
448
+ entry.connectionLabel ?? null,
449
+ entry.sql,
450
+ Number(entry.statementCount ?? 0),
451
+ entry.resultKind ?? null,
452
+ Number(entry.affectedRowCount ?? 0),
453
+ Number(entry.rowCount ?? 0),
454
+ Number(entry.timingMs ?? 0),
455
+ entry.executedAt
456
+ );
457
+
458
+ this.trimSqlHistory();
459
+ })();
460
+
461
+ return this.getSqlHistory();
462
+ }
463
+
464
+ clearSqlHistory() {
465
+ this.db.prepare("DELETE FROM sql_history").run();
466
+ return [];
467
+ }
468
+
469
+ getSqlHistory() {
470
+ return this.db
471
+ .prepare(`
472
+ SELECT
473
+ id,
474
+ connectionId,
475
+ connectionLabel,
476
+ sql,
477
+ statementCount,
478
+ resultKind,
479
+ affectedRowCount,
480
+ rowCount,
481
+ timingMs,
482
+ executedAt
483
+ FROM sql_history
484
+ ORDER BY executedAt DESC, id ASC
485
+ `)
486
+ .all()
487
+ .map((entry) => ({
488
+ ...entry,
489
+ statementCount: Number(entry.statementCount ?? 0),
490
+ affectedRowCount: Number(entry.affectedRowCount ?? 0),
491
+ rowCount: Number(entry.rowCount ?? 0),
492
+ timingMs: Number(entry.timingMs ?? 0),
493
+ }));
494
+ }
495
+
496
+ getSettings() {
497
+ const rows = this.db.prepare("SELECT key, value FROM settings").all();
498
+ const parsedSettings = Object.fromEntries(
499
+ rows.map((row) => [row.key, this.parseStoredValue(row.value)])
500
+ );
501
+
502
+ return {
503
+ ...DEFAULT_STATE.settings,
504
+ ...parsedSettings,
505
+ };
506
+ }
507
+
508
+ patchSettings(partialSettings) {
509
+ const entries = Object.entries(partialSettings ?? {});
510
+
511
+ this.db.transaction(() => {
512
+ for (const [key, value] of entries) {
513
+ this.db
514
+ .prepare(`
515
+ INSERT INTO settings (key, value)
516
+ VALUES (?, ?)
517
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
518
+ `)
519
+ .run(key, JSON.stringify(value));
520
+ }
521
+ })();
522
+
523
+ return this.getSettings();
524
+ }
525
+ }
526
+
527
+ module.exports = {
528
+ AppStateStore,
529
+ DEFAULT_STATE,
530
+ };
@@ -0,0 +1,34 @@
1
+ function escapeCsvCell(value, delimiter) {
2
+ if (value === null || value === undefined) {
3
+ return "";
4
+ }
5
+
6
+ const stringValue =
7
+ typeof value === "object" ? JSON.stringify(value) : String(value);
8
+
9
+ if (
10
+ stringValue.includes('"') ||
11
+ stringValue.includes("\n") ||
12
+ stringValue.includes("\r") ||
13
+ stringValue.includes(delimiter)
14
+ ) {
15
+ return `"${stringValue.replaceAll('"', '""')}"`;
16
+ }
17
+
18
+ return stringValue;
19
+ }
20
+
21
+ function rowsToCsv({ columns, rows, delimiter = "," }) {
22
+ const header = columns.map((column) => escapeCsvCell(column, delimiter)).join(delimiter);
23
+ const body = rows.map((row) =>
24
+ columns
25
+ .map((column) => escapeCsvCell(row[column], delimiter))
26
+ .join(delimiter)
27
+ );
28
+
29
+ return [header, ...body].join("\n");
30
+ }
31
+
32
+ module.exports = {
33
+ rowsToCsv,
34
+ };