sqlite-hub 0.1.3 → 0.3.1

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.
package/publish_brew.sh CHANGED
@@ -304,6 +304,27 @@ commit_and_push_tap() {
304
304
  run git -C "$TAP_DIR" push origin HEAD
305
305
  }
306
306
 
307
+ refresh_local_brew_tap_checkout() {
308
+ local brew_repo
309
+ local installed_tap_path
310
+
311
+ if ! command -v brew >/dev/null 2>&1; then
312
+ info "brew not found, skipping local tap refresh"
313
+ return
314
+ fi
315
+
316
+ brew_repo="$(brew --repository)"
317
+ installed_tap_path="${brew_repo}/Library/Taps/${TAP_OWNER}/${TAP_REPO_NAME}"
318
+
319
+ if [[ ! -d "$installed_tap_path/.git" ]]; then
320
+ info "No installed Homebrew tap checkout at ${installed_tap_path}, skipping local refresh"
321
+ return
322
+ fi
323
+
324
+ info "Refreshing installed Homebrew tap checkout at ${installed_tap_path}"
325
+ run git -C "$installed_tap_path" pull --ff-only
326
+ }
327
+
307
328
  run_brew_audit() {
308
329
  if [[ "$SKIP_AUDIT" == "1" ]]; then
309
330
  info "Skipping brew audit"
@@ -439,6 +460,7 @@ ensure_tap_repo
439
460
  write_formula
440
461
  run_brew_audit
441
462
  commit_and_push_tap
463
+ refresh_local_brew_tap_checkout
442
464
 
443
465
  info "Published ${FORMULA_NAME} ${VERSION}"
444
466
  info "Install with: brew install ${BREW_TAP_NAME}/${FORMULA_NAME}"
@@ -1,7 +1,7 @@
1
1
  const express = require("express");
2
2
  const { route, successResponse } = require("../utils/errors");
3
3
 
4
- function createConnectionsRouter({ connectionManager, importService }) {
4
+ function createConnectionsRouter({ connectionManager, importService, backupService }) {
5
5
  const router = express.Router();
6
6
 
7
7
  router.post(
@@ -68,6 +68,20 @@ function createConnectionsRouter({ connectionManager, importService }) {
68
68
  })
69
69
  );
70
70
 
71
+ router.post(
72
+ "/backup-active",
73
+ route((req, res) => {
74
+ const backup = backupService.createActiveBackup();
75
+
76
+ res.json(
77
+ successResponse({
78
+ message: `Backup created: ${backup.fileName}`,
79
+ data: backup,
80
+ })
81
+ );
82
+ })
83
+ );
84
+
71
85
  router.get(
72
86
  "/recent",
73
87
  route((req, res) => {
@@ -4,16 +4,36 @@ const { route } = require("../utils/errors");
4
4
  function createExportRouter({ exportService }) {
5
5
  const router = express.Router();
6
6
 
7
+ function sendCsv(res, result) {
8
+ res.setHeader("Content-Type", "text/csv; charset=utf-8");
9
+ res.setHeader(
10
+ "Content-Disposition",
11
+ `attachment; filename="${result.filename}"`
12
+ );
13
+ res.send(result.csv);
14
+ }
15
+
7
16
  router.post(
8
17
  "/query.csv",
9
18
  route((req, res) => {
10
19
  const result = exportService.exportQuery(req.body.sql);
11
- res.setHeader("Content-Type", "text/csv; charset=utf-8");
12
- res.setHeader(
13
- "Content-Disposition",
14
- `attachment; filename="${result.filename}"`
15
- );
16
- res.send(result.csv);
20
+ sendCsv(res, result);
21
+ })
22
+ );
23
+
24
+ router.post(
25
+ "/table.csv",
26
+ route((req, res) => {
27
+ const result = exportService.exportTable(req.body?.tableName);
28
+ sendCsv(res, result);
29
+ })
30
+ );
31
+
32
+ router.get(
33
+ "/table/:tableName.csv",
34
+ route((req, res) => {
35
+ const result = exportService.exportTable(req.params.tableName);
36
+ sendCsv(res, result);
17
37
  })
18
38
  );
19
39
 
package/server/server.js CHANGED
@@ -1,11 +1,13 @@
1
1
  const express = require("express");
2
2
  const path = require("node:path");
3
3
  const { errorMiddleware } = require("./utils/errors");
4
+ const { resolveAppStatePaths } = require("./utils/appPaths");
4
5
  const { AppStateStore } = require("./services/storage/appStateStore");
5
6
  const { ConnectionManager } = require("./services/sqlite/connectionManager");
6
7
  const { OverviewService } = require("./services/sqlite/overviewService");
7
8
  const { SqlExecutor } = require("./services/sqlite/sqlExecutor");
8
9
  const { ImportService } = require("./services/sqlite/importService");
10
+ const { BackupService } = require("./services/sqlite/backupService");
9
11
  const { ExportService } = require("./services/sqlite/exportService");
10
12
  const { StructureService } = require("./services/sqlite/structureService");
11
13
  const { DataBrowserService } = require("./services/sqlite/dataBrowserService");
@@ -17,19 +19,26 @@ const { createDataRouter } = require("./routes/data");
17
19
  const { createSettingsRouter } = require("./routes/settings");
18
20
  const { createExportRouter } = require("./routes/export");
19
21
 
20
- const APP_STATE_DB_PATH = path.resolve(__dirname, "..", "data", "sqlite-hub-state.db");
21
- const LEGACY_STATE_PATH = path.resolve(__dirname, "..", "data", "app-state.json");
22
+ const PACKAGE_ROOT = path.resolve(__dirname, "..");
23
+ const {
24
+ appStateDbPath: APP_STATE_DB_PATH,
25
+ legacyStatePath: LEGACY_STATE_PATH,
26
+ legacyDatabasePaths: LEGACY_DATABASE_PATHS,
27
+ } = resolveAppStatePaths(PACKAGE_ROOT);
22
28
  const DEFAULT_PORT = 4173;
23
29
 
24
30
  const appStateStore = new AppStateStore(APP_STATE_DB_PATH, {
25
31
  legacyFilePath: LEGACY_STATE_PATH,
32
+ legacyDatabasePaths: LEGACY_DATABASE_PATHS,
26
33
  });
27
34
  const connectionManager = new ConnectionManager({ appStateStore });
28
35
  const overviewService = new OverviewService({ connectionManager });
29
36
  const sqlExecutor = new SqlExecutor({ connectionManager, appStateStore });
30
37
  const importService = new ImportService({ connectionManager });
38
+ const backupService = new BackupService({ connectionManager });
31
39
  const exportService = new ExportService({
32
40
  appStateStore,
41
+ connectionManager,
33
42
  sqlExecutor,
34
43
  });
35
44
  const structureService = new StructureService({ connectionManager, appStateStore });
@@ -59,6 +68,7 @@ app.use(
59
68
  createConnectionsRouter({
60
69
  connectionManager,
61
70
  importService,
71
+ backupService,
62
72
  })
63
73
  );
64
74
  app.use("/api/db", createOverviewRouter({ overviewService }));
@@ -80,6 +90,18 @@ app.get("/index.html", (req, res) => {
80
90
  res.sendFile(path.resolve(__dirname, "..", "index.html"));
81
91
  });
82
92
 
93
+ app.use(
94
+ "/vendor/cytoscape",
95
+ express.static(path.resolve(__dirname, "..", "node_modules", "cytoscape"))
96
+ );
97
+ app.use(
98
+ "/vendor/cytoscape-elk",
99
+ express.static(path.resolve(__dirname, "..", "node_modules", "cytoscape-elk"))
100
+ );
101
+ app.use(
102
+ "/vendor/elkjs",
103
+ express.static(path.resolve(__dirname, "..", "node_modules", "elkjs"))
104
+ );
83
105
  app.use("/js", express.static(path.resolve(__dirname, "..", "js")));
84
106
  app.use("/styles", express.static(path.resolve(__dirname, "..", "styles")));
85
107
  app.use("/assets", express.static(path.resolve(__dirname, "..", "assets")));
@@ -0,0 +1,112 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const { DatabaseRequiredError } = require("../../utils/errors");
4
+ const {
5
+ SQLITE_EXTENSIONS,
6
+ ensureParentDirectory,
7
+ getFileMetadata,
8
+ validateSqlitePath,
9
+ } = require("../../utils/fileValidation");
10
+
11
+ function padTimestampPart(value) {
12
+ return String(value).padStart(2, "0");
13
+ }
14
+
15
+ function formatBackupTimestamp(date) {
16
+ return [
17
+ date.getFullYear(),
18
+ padTimestampPart(date.getMonth() + 1),
19
+ padTimestampPart(date.getDate()),
20
+ ].join("-")
21
+ + "_"
22
+ + [
23
+ padTimestampPart(date.getHours()),
24
+ padTimestampPart(date.getMinutes()),
25
+ padTimestampPart(date.getSeconds()),
26
+ ].join("-");
27
+ }
28
+
29
+ function sanitizeBackupBaseName(value) {
30
+ const normalized = String(value ?? "").trim().replace(/[<>:"/\\|?*\u0000-\u001F]+/g, "_");
31
+ return normalized || "database";
32
+ }
33
+
34
+ function normalizeBackupLabelBase(value, fallback) {
35
+ const normalized = String(value ?? "").trim() || String(fallback ?? "").trim() || "database";
36
+ const extension = path.extname(normalized).toLowerCase();
37
+
38
+ if (SQLITE_EXTENSIONS.has(extension)) {
39
+ return normalized.slice(0, -extension.length) || "database";
40
+ }
41
+
42
+ return normalized;
43
+ }
44
+
45
+ class BackupService {
46
+ constructor({ connectionManager }) {
47
+ this.connectionManager = connectionManager;
48
+ }
49
+
50
+ createActiveBackup() {
51
+ const activeConnection = this.connectionManager.getActiveConnection();
52
+
53
+ if (!activeConnection) {
54
+ throw new DatabaseRequiredError("No active SQLite database selected for backup.");
55
+ }
56
+
57
+ const sourcePath = validateSqlitePath(activeConnection.path, { mustExist: true });
58
+ const { backupPath, connectionLabel } = this.buildBackupPath(sourcePath, activeConnection.label);
59
+
60
+ ensureParentDirectory(backupPath);
61
+ fs.copyFileSync(sourcePath, backupPath, fs.constants.COPYFILE_EXCL);
62
+
63
+ const backupConnection = this.connectionManager.rememberConnection({
64
+ filePath: backupPath,
65
+ label: connectionLabel,
66
+ makeActive: false,
67
+ });
68
+ const metadata = getFileMetadata(backupPath);
69
+
70
+ return {
71
+ sourcePath,
72
+ backupPath,
73
+ directory: path.dirname(backupPath),
74
+ fileName: path.basename(backupPath),
75
+ createdAt: new Date().toISOString(),
76
+ sizeBytes: metadata.sizeBytes,
77
+ connection: backupConnection,
78
+ };
79
+ }
80
+
81
+ buildBackupPath(sourcePath, sourceLabel) {
82
+ const parsedSource = path.parse(sourcePath);
83
+ const backupDirectory = path.join(parsedSource.dir, "backups");
84
+ const extension = parsedSource.ext || ".sqlite";
85
+ const displayBaseName = normalizeBackupLabelBase(sourceLabel, parsedSource.name);
86
+ const fileBaseName = sanitizeBackupBaseName(displayBaseName);
87
+ const timestamp = formatBackupTimestamp(new Date());
88
+ let attempt = 1;
89
+
90
+ while (true) {
91
+ const suffix = attempt === 1 ? "" : `_${String(attempt).padStart(2, "0")}`;
92
+ const fileStem = `${fileBaseName}_${timestamp}${suffix}`;
93
+ const candidate = path.join(
94
+ backupDirectory,
95
+ `${fileStem}${extension}`
96
+ );
97
+
98
+ if (!fs.existsSync(candidate)) {
99
+ return {
100
+ backupPath: candidate,
101
+ connectionLabel: `${displayBaseName}_${timestamp}${suffix}`,
102
+ };
103
+ }
104
+
105
+ attempt += 1;
106
+ }
107
+ }
108
+ }
109
+
110
+ module.exports = {
111
+ BackupService,
112
+ };
@@ -121,6 +121,21 @@ class ConnectionManager {
121
121
  return this.getActiveConnection();
122
122
  }
123
123
 
124
+ rememberConnection({ filePath, label, readOnly = false, makeActive = false }) {
125
+ const resolvedPath = validateSqlitePath(filePath, { mustExist: true });
126
+ const connection = this.buildConnectionRecord(resolvedPath, {
127
+ label,
128
+ readOnly,
129
+ });
130
+
131
+ this.appStateStore.upsertRecentConnection(connection, { makeActive });
132
+
133
+ return {
134
+ ...connection,
135
+ isActive: this.current?.id === connection.id,
136
+ };
137
+ }
138
+
124
139
  createConnection({ filePath, label }) {
125
140
  const resolvedPath = resolveUserPath(filePath);
126
141
  ensureFileDoesNotExist(resolvedPath, "SQLite database");
@@ -1,8 +1,12 @@
1
+ const { quoteIdentifier } = require("../../utils/identifier");
2
+ const { serializeRows } = require("../../utils/sqliteTypes");
1
3
  const { rowsToCsv } = require("../../utils/csv");
4
+ const { getTableDetail } = require("./introspection");
2
5
 
3
6
  class ExportService {
4
- constructor({ appStateStore, sqlExecutor }) {
7
+ constructor({ appStateStore, connectionManager, sqlExecutor }) {
5
8
  this.appStateStore = appStateStore;
9
+ this.connectionManager = connectionManager;
6
10
  this.sqlExecutor = sqlExecutor;
7
11
  }
8
12
 
@@ -27,6 +31,47 @@ class ExportService {
27
31
  rowCount: result.rows.length,
28
32
  };
29
33
  }
34
+
35
+ exportTable(tableName) {
36
+ const db = this.connectionManager.getActiveDatabase();
37
+ const tableDetail = getTableDetail(db, tableName, { includeRowCount: false });
38
+ const orderClause = this.buildOrderClause(tableDetail);
39
+ const statement = db.prepare(
40
+ [
41
+ `SELECT * FROM ${quoteIdentifier(tableName)}`,
42
+ orderClause ? `ORDER BY ${orderClause}` : "",
43
+ ]
44
+ .filter(Boolean)
45
+ .join(" ")
46
+ );
47
+ const rows = serializeRows(statement.all());
48
+ const columns = statement.columns().map((column) => column.name);
49
+
50
+ return {
51
+ filename: `${tableName}.csv`,
52
+ csv: rowsToCsv({
53
+ columns,
54
+ rows,
55
+ delimiter: this.getDelimiter(),
56
+ }),
57
+ columns,
58
+ rowCount: rows.length,
59
+ };
60
+ }
61
+
62
+ buildOrderClause(tableDetail) {
63
+ if (tableDetail.identityStrategy?.type === "rowid") {
64
+ return "rowid ASC";
65
+ }
66
+
67
+ if (tableDetail.identityStrategy?.type === "primaryKey") {
68
+ return tableDetail.identityStrategy.columns
69
+ .map((columnName) => `${quoteIdentifier(columnName)} ASC`)
70
+ .join(", ");
71
+ }
72
+
73
+ return "";
74
+ }
30
75
  }
31
76
 
32
77
  module.exports = {
@@ -11,6 +11,18 @@ class StructureService {
11
11
  getStructureOverview() {
12
12
  const db = this.connectionManager.getActiveDatabase();
13
13
  const entries = getRawStructureEntries(db);
14
+ const tables = entries
15
+ .filter((entry) => entry.type === "table")
16
+ .map((entry) => getTableDetail(db, entry.name, { includeRowCount: false }));
17
+ const relationshipCount = tables.reduce(
18
+ (count, table) =>
19
+ count +
20
+ table.foreignKeys.reduce(
21
+ (tableCount, foreignKey) => tableCount + foreignKey.mappings.length,
22
+ 0
23
+ ),
24
+ 0
25
+ );
14
26
 
15
27
  return {
16
28
  entries,
@@ -20,6 +32,20 @@ class StructureService {
20
32
  indexes: entries.filter((entry) => entry.type === "index"),
21
33
  triggers: entries.filter((entry) => entry.type === "trigger"),
22
34
  },
35
+ graph: {
36
+ tables: tables.map((table) => ({
37
+ type: table.type,
38
+ name: table.name,
39
+ ddl: table.ddl,
40
+ withoutRowId: table.withoutRowId,
41
+ strict: table.strict,
42
+ columns: table.columns,
43
+ foreignKeys: table.foreignKeys,
44
+ identityStrategy: table.identityStrategy,
45
+ notSafelyUpdatable: table.notSafelyUpdatable,
46
+ })),
47
+ relationshipCount,
48
+ },
23
49
  };
24
50
  }
25
51
 
@@ -20,6 +20,9 @@ class AppStateStore {
20
20
  constructor(filePath, options = {}) {
21
21
  this.filePath = filePath;
22
22
  this.legacyFilePath = options.legacyFilePath ?? null;
23
+ this.legacyDatabasePaths = Array.isArray(options.legacyDatabasePaths)
24
+ ? options.legacyDatabasePaths
25
+ : [];
23
26
  this.isFreshDatabase = !fs.existsSync(filePath);
24
27
 
25
28
  fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
@@ -29,8 +32,10 @@ class AppStateStore {
29
32
  this.ensureSchema();
30
33
  this.seedDefaultSettings();
31
34
 
32
- if (this.shouldImportLegacyState()) {
33
- this.importLegacyState();
35
+ const importedLegacyDatabase = this.importFirstLegacyDatabase();
36
+
37
+ if (!importedLegacyDatabase && this.shouldImportLegacyState()) {
38
+ this.tryImportLegacyState();
34
39
  }
35
40
  }
36
41
 
@@ -121,8 +126,90 @@ class AppStateStore {
121
126
  };
122
127
  }
123
128
 
124
- importLegacyState() {
125
- const legacyState = this.readLegacyState();
129
+ getExistingLegacyDatabasePaths() {
130
+ return this.legacyDatabasePaths
131
+ .filter(Boolean)
132
+ .map((legacyPath) => path.resolve(legacyPath))
133
+ .filter(
134
+ (legacyPath, index, legacyPaths) =>
135
+ legacyPath !== path.resolve(this.filePath) &&
136
+ legacyPaths.indexOf(legacyPath) === index &&
137
+ fs.existsSync(legacyPath)
138
+ );
139
+ }
140
+
141
+ readLegacyDatabase(legacyDatabasePath) {
142
+ const legacyDb = new Database(legacyDatabasePath, {
143
+ readonly: true,
144
+ fileMustExist: true,
145
+ });
146
+
147
+ try {
148
+ legacyDb.pragma("query_only = ON");
149
+
150
+ const tables = new Set(
151
+ legacyDb
152
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
153
+ .all()
154
+ .map((row) => row.name)
155
+ );
156
+
157
+ return {
158
+ settings: tables.has("settings")
159
+ ? Object.fromEntries(
160
+ legacyDb
161
+ .prepare("SELECT key, value FROM settings")
162
+ .all()
163
+ .map((row) => [row.key, row.value])
164
+ )
165
+ : {},
166
+ recentConnections: tables.has("recent_connections")
167
+ ? legacyDb
168
+ .prepare(`
169
+ SELECT
170
+ id,
171
+ label,
172
+ path,
173
+ lastOpenedAt,
174
+ lastModifiedAt,
175
+ sizeBytes,
176
+ readOnly
177
+ FROM recent_connections
178
+ ORDER BY lastOpenedAt DESC, id ASC
179
+ `)
180
+ .all()
181
+ : [],
182
+ sqlHistory: tables.has("sql_history")
183
+ ? legacyDb
184
+ .prepare(`
185
+ SELECT
186
+ id,
187
+ connectionId,
188
+ connectionLabel,
189
+ sql,
190
+ statementCount,
191
+ resultKind,
192
+ affectedRowCount,
193
+ rowCount,
194
+ timingMs,
195
+ executedAt
196
+ FROM sql_history
197
+ ORDER BY executedAt DESC, id ASC
198
+ `)
199
+ .all()
200
+ : [],
201
+ activeConnectionId: tables.has("app_meta")
202
+ ? legacyDb
203
+ .prepare("SELECT value FROM app_meta WHERE key = ?")
204
+ .get("activeConnectionId")?.value ?? null
205
+ : null,
206
+ };
207
+ } finally {
208
+ legacyDb.close();
209
+ }
210
+ }
211
+
212
+ importStateSnapshot(legacyState, sourcePath) {
126
213
  const insertSetting = this.db.prepare(`
127
214
  INSERT INTO settings (key, value)
128
215
  VALUES (?, ?)
@@ -175,7 +262,10 @@ class AppStateStore {
175
262
 
176
263
  this.db.transaction(() => {
177
264
  for (const [key, value] of Object.entries(legacyState.settings ?? {})) {
178
- insertSetting.run(key, JSON.stringify(value));
265
+ const normalizedValue =
266
+ typeof value === "string" ? this.parseStoredValue(value) : value;
267
+
268
+ insertSetting.run(key, JSON.stringify(normalizedValue));
179
269
  }
180
270
 
181
271
  for (const connection of legacyState.recentConnections ?? []) {
@@ -208,11 +298,43 @@ class AppStateStore {
208
298
  this.setMetaValue("activeConnectionId", legacyState.activeConnectionId ?? null);
209
299
  this.setMetaValue(
210
300
  "legacyImportSource",
211
- path.relative(path.dirname(this.filePath), this.legacyFilePath)
301
+ path.relative(path.dirname(this.filePath), sourcePath)
212
302
  );
213
303
  })();
214
304
  }
215
305
 
306
+ importFirstLegacyDatabase() {
307
+ if (!this.isFreshDatabase) {
308
+ return false;
309
+ }
310
+
311
+ for (const legacyDatabasePath of this.getExistingLegacyDatabasePaths()) {
312
+ try {
313
+ this.importStateSnapshot(
314
+ this.readLegacyDatabase(legacyDatabasePath),
315
+ legacyDatabasePath
316
+ );
317
+ return true;
318
+ } catch (error) {
319
+ console.warn(
320
+ `Could not import legacy app state database from ${legacyDatabasePath}: ${error.message}`
321
+ );
322
+ }
323
+ }
324
+
325
+ return false;
326
+ }
327
+
328
+ tryImportLegacyState() {
329
+ try {
330
+ this.importStateSnapshot(this.readLegacyState(), this.legacyFilePath);
331
+ } catch (error) {
332
+ console.warn(
333
+ `Could not import legacy app state from ${this.legacyFilePath}: ${error.message}`
334
+ );
335
+ }
336
+ }
337
+
216
338
  parseStoredValue(value) {
217
339
  try {
218
340
  return JSON.parse(value);
@@ -322,7 +444,9 @@ class AppStateStore {
322
444
  }));
323
445
  }
324
446
 
325
- upsertRecentConnection(connection) {
447
+ upsertRecentConnection(connection, options = {}) {
448
+ const makeActive = options.makeActive !== false;
449
+
326
450
  this.db.transaction(() => {
327
451
  this.db
328
452
  .prepare(`
@@ -354,7 +478,10 @@ class AppStateStore {
354
478
  connection.readOnly ? 1 : 0
355
479
  );
356
480
 
357
- this.setMetaValue("activeConnectionId", connection.id);
481
+ if (makeActive) {
482
+ this.setMetaValue("activeConnectionId", connection.id);
483
+ }
484
+
358
485
  this.trimRecentConnections();
359
486
  })();
360
487