sqlite-hub 0.2.0 → 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
@@ -7,6 +7,7 @@ const { ConnectionManager } = require("./services/sqlite/connectionManager");
7
7
  const { OverviewService } = require("./services/sqlite/overviewService");
8
8
  const { SqlExecutor } = require("./services/sqlite/sqlExecutor");
9
9
  const { ImportService } = require("./services/sqlite/importService");
10
+ const { BackupService } = require("./services/sqlite/backupService");
10
11
  const { ExportService } = require("./services/sqlite/exportService");
11
12
  const { StructureService } = require("./services/sqlite/structureService");
12
13
  const { DataBrowserService } = require("./services/sqlite/dataBrowserService");
@@ -34,8 +35,10 @@ const connectionManager = new ConnectionManager({ appStateStore });
34
35
  const overviewService = new OverviewService({ connectionManager });
35
36
  const sqlExecutor = new SqlExecutor({ connectionManager, appStateStore });
36
37
  const importService = new ImportService({ connectionManager });
38
+ const backupService = new BackupService({ connectionManager });
37
39
  const exportService = new ExportService({
38
40
  appStateStore,
41
+ connectionManager,
39
42
  sqlExecutor,
40
43
  });
41
44
  const structureService = new StructureService({ connectionManager, appStateStore });
@@ -65,6 +68,7 @@ app.use(
65
68
  createConnectionsRouter({
66
69
  connectionManager,
67
70
  importService,
71
+ backupService,
68
72
  })
69
73
  );
70
74
  app.use("/api/db", createOverviewRouter({ overviewService }));
@@ -86,6 +90,18 @@ app.get("/index.html", (req, res) => {
86
90
  res.sendFile(path.resolve(__dirname, "..", "index.html"));
87
91
  });
88
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
+ );
89
105
  app.use("/js", express.static(path.resolve(__dirname, "..", "js")));
90
106
  app.use("/styles", express.static(path.resolve(__dirname, "..", "styles")));
91
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
 
@@ -444,7 +444,9 @@ class AppStateStore {
444
444
  }));
445
445
  }
446
446
 
447
- upsertRecentConnection(connection) {
447
+ upsertRecentConnection(connection, options = {}) {
448
+ const makeActive = options.makeActive !== false;
449
+
448
450
  this.db.transaction(() => {
449
451
  this.db
450
452
  .prepare(`
@@ -476,7 +478,10 @@ class AppStateStore {
476
478
  connection.readOnly ? 1 : 0
477
479
  );
478
480
 
479
- this.setMetaValue("activeConnectionId", connection.id);
481
+ if (makeActive) {
482
+ this.setMetaValue("activeConnectionId", connection.id);
483
+ }
484
+
480
485
  this.trimRecentConnections();
481
486
  })();
482
487