sqlite-hub 0.16.0 → 0.17.2

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 (53) hide show
  1. package/README.md +106 -19
  2. package/bin/sqlite-hub.js +1041 -224
  3. package/frontend/index.html +1 -0
  4. package/frontend/js/api.js +34 -0
  5. package/frontend/js/app.js +481 -32
  6. package/frontend/js/components/modal.js +270 -50
  7. package/frontend/js/components/pageHeader.js +14 -16
  8. package/frontend/js/components/queryHistoryPanel.js +126 -131
  9. package/frontend/js/components/queryResults.js +18 -1
  10. package/frontend/js/components/rowEditorPanel.js +42 -34
  11. package/frontend/js/components/sidebar.js +1 -0
  12. package/frontend/js/router.js +8 -0
  13. package/frontend/js/store.js +655 -2
  14. package/frontend/js/utils/jsonPreview.js +31 -0
  15. package/frontend/js/utils/markdownDocuments.js +248 -0
  16. package/frontend/js/utils/rowEditorValues.js +41 -0
  17. package/frontend/js/utils/tableScrollState.js +39 -0
  18. package/frontend/js/views/charts.js +8 -0
  19. package/frontend/js/views/data.js +4 -1
  20. package/frontend/js/views/documents.js +300 -0
  21. package/frontend/js/views/editor.js +2 -0
  22. package/frontend/js/views/settings.js +39 -3
  23. package/frontend/styles/components.css +19 -0
  24. package/frontend/styles/tailwind.generated.css +1 -1
  25. package/frontend/styles/views.css +422 -0
  26. package/package.json +2 -1
  27. package/server/middleware/localRequestSecurity.js +84 -0
  28. package/server/routes/connections.js +18 -1
  29. package/server/routes/documents.js +163 -0
  30. package/server/routes/settings.js +22 -6
  31. package/server/server.js +18 -1
  32. package/server/services/nativeFileDialogService.js +168 -0
  33. package/server/services/sqlite/dataBrowserService.js +0 -4
  34. package/server/services/sqlite/exportService.js +5 -1
  35. package/server/services/sqlite/sqlExecutor.js +51 -2
  36. package/server/services/storage/appStateStore.js +313 -0
  37. package/server/utils/sqliteTypes.js +17 -8
  38. package/tests/cli-args.test.js +100 -0
  39. package/tests/connections-file-dialog-route.test.js +46 -0
  40. package/tests/copy-column-modal.test.js +97 -0
  41. package/tests/database-documents.test.js +85 -0
  42. package/tests/export-blob.test.js +46 -0
  43. package/tests/json-preview.test.js +49 -0
  44. package/tests/local-request-security.test.js +85 -0
  45. package/tests/markdown-documents.test.js +79 -0
  46. package/tests/native-file-dialog.test.js +78 -0
  47. package/tests/query-results-truncation.test.js +20 -0
  48. package/tests/row-editor-null-values.test.js +131 -0
  49. package/tests/settings-metadata.test.js +16 -0
  50. package/tests/settings-view.test.js +32 -0
  51. package/tests/sql-identifier-safety.test.js +9 -3
  52. package/tests/sql-result-limit.test.js +66 -0
  53. package/tests/table-scroll-state.test.js +70 -0
@@ -1,4 +1,5 @@
1
1
  const express = require("express");
2
+ const Database = require("better-sqlite3");
2
3
  const fs = require("node:fs");
3
4
  const path = require("node:path");
4
5
  const { route, successResponse } = require("../utils/errors");
@@ -9,6 +10,23 @@ function readAppVersion() {
9
10
  return packageJson.version ?? "0.0.0";
10
11
  }
11
12
 
13
+ function readSqliteVersion() {
14
+ const db = new Database(":memory:");
15
+
16
+ try {
17
+ return db.prepare("SELECT sqlite_version() AS version").get().version ?? "unknown";
18
+ } finally {
19
+ db.close();
20
+ }
21
+ }
22
+
23
+ function readSettingsMetadata() {
24
+ return {
25
+ appVersion: readAppVersion(),
26
+ sqliteVersion: readSqliteVersion(),
27
+ };
28
+ }
29
+
12
30
  function createSettingsRouter({ appStateStore }) {
13
31
  const router = express.Router();
14
32
 
@@ -18,9 +36,7 @@ function createSettingsRouter({ appStateStore }) {
18
36
  res.json(
19
37
  successResponse({
20
38
  data: appStateStore.getSettings(),
21
- metadata: {
22
- appVersion: readAppVersion(),
23
- },
39
+ metadata: readSettingsMetadata(),
24
40
  })
25
41
  );
26
42
  })
@@ -34,9 +50,7 @@ function createSettingsRouter({ appStateStore }) {
34
50
  successResponse({
35
51
  message: "Settings updated.",
36
52
  data: settings,
37
- metadata: {
38
- appVersion: readAppVersion(),
39
- },
53
+ metadata: readSettingsMetadata(),
40
54
  })
41
55
  );
42
56
  })
@@ -47,4 +61,6 @@ function createSettingsRouter({ appStateStore }) {
47
61
 
48
62
  module.exports = {
49
63
  createSettingsRouter,
64
+ readSettingsMetadata,
65
+ readSqliteVersion,
50
66
  };
package/server/server.js CHANGED
@@ -3,6 +3,11 @@ const helmet = require("helmet");
3
3
  const rateLimit = require("express-rate-limit");
4
4
  const path = require("node:path");
5
5
  const { errorMiddleware } = require("./utils/errors");
6
+ const {
7
+ LOOPBACK_HOST,
8
+ listenOnLoopback,
9
+ localRequestSecurity,
10
+ } = require("./middleware/localRequestSecurity");
6
11
  const { resolveAppStatePaths } = require("./utils/appPaths");
7
12
  const { AppStateStore } = require("./services/storage/appStateStore");
8
13
  const { ConnectionManager } = require("./services/sqlite/connectionManager");
@@ -10,6 +15,7 @@ const { OverviewService } = require("./services/sqlite/overviewService");
10
15
  const { SqlExecutor } = require("./services/sqlite/sqlExecutor");
11
16
  const { ImportService } = require("./services/sqlite/importService");
12
17
  const { BackupService } = require("./services/sqlite/backupService");
18
+ const { NativeFileDialogService } = require("./services/nativeFileDialogService");
13
19
  const { ExportService } = require("./services/sqlite/exportService");
14
20
  const { StructureService } = require("./services/sqlite/structureService");
15
21
  const { DataBrowserService } = require("./services/sqlite/dataBrowserService");
@@ -25,6 +31,7 @@ const { createTableDesignerRouter } = require("./routes/tableDesigner");
25
31
  const { createMediaTaggingRouter } = require("./routes/mediaTagging");
26
32
  const { createSettingsRouter } = require("./routes/settings");
27
33
  const { createExportRouter } = require("./routes/export");
34
+ const { createDocumentsRouter } = require("./routes/documents");
28
35
 
29
36
  const PACKAGE_ROOT = path.resolve(__dirname, "..");
30
37
  const FRONTEND_ROOT = path.join(PACKAGE_ROOT, "frontend");
@@ -36,6 +43,7 @@ const {
36
43
  legacyDatabasePaths: LEGACY_DATABASE_PATHS,
37
44
  } = resolveAppStatePaths(PACKAGE_ROOT);
38
45
  const DEFAULT_PORT = 4173;
46
+ const DEFAULT_HOST = LOOPBACK_HOST;
39
47
 
40
48
  const appStateStore = new AppStateStore(APP_STATE_DB_PATH, {
41
49
  legacyFilePath: LEGACY_STATE_PATH,
@@ -46,6 +54,7 @@ const overviewService = new OverviewService({ connectionManager });
46
54
  const sqlExecutor = new SqlExecutor({ connectionManager, appStateStore });
47
55
  const importService = new ImportService({ connectionManager });
48
56
  const backupService = new BackupService({ connectionManager });
57
+ const nativeFileDialogService = new NativeFileDialogService();
49
58
  const exportService = new ExportService({
50
59
  appStateStore,
51
60
  connectionManager,
@@ -65,6 +74,7 @@ app.use(
65
74
  contentSecurityPolicy: false,
66
75
  })
67
76
  );
77
+ app.use("/api", localRequestSecurity);
68
78
  app.use(
69
79
  "/api",
70
80
  rateLimit({
@@ -96,6 +106,7 @@ app.use(
96
106
  connectionManager,
97
107
  importService,
98
108
  backupService,
109
+ nativeFileDialogService,
99
110
  })
100
111
  );
101
112
  app.use("/api/db", createOverviewRouter({ overviewService }));
@@ -107,6 +118,7 @@ app.use("/api/table-designer", createTableDesignerRouter({ tableDesignerService
107
118
  app.use("/api/media-tagging", createMediaTaggingRouter({ mediaTaggingService }));
108
119
  app.use("/api/settings", createSettingsRouter({ appStateStore }));
109
120
  app.use("/api/export", createExportRouter({ exportService }));
121
+ app.use("/api/documents", createDocumentsRouter({ appStateStore, connectionManager }));
110
122
 
111
123
  // auth: public favicon response; it exposes no application data.
112
124
  app.get("/favicon.ico", (req, res) => {
@@ -143,6 +155,10 @@ app.use(
143
155
  "/vendor/material-symbols",
144
156
  express.static(path.resolve(__dirname, "..", "node_modules", "material-symbols"))
145
157
  );
158
+ app.use(
159
+ "/vendor/marked",
160
+ express.static(path.resolve(__dirname, "..", "node_modules", "marked"))
161
+ );
146
162
  app.use(express.static(FRONTEND_ROOT));
147
163
  app.use("/db_logos", express.static(path.join(APP_STATE_DIRECTORY, "db_logos")));
148
164
  app.use(errorMiddleware);
@@ -185,7 +201,7 @@ function startServer({ port } = {}) {
185
201
  const resolvedPort = resolvePort(port);
186
202
 
187
203
  return new Promise((resolve, reject) => {
188
- const server = app.listen(resolvedPort);
204
+ const server = listenOnLoopback(app, resolvedPort);
189
205
 
190
206
  server.once("error", reject);
191
207
  server.once("listening", () => {
@@ -212,6 +228,7 @@ module.exports = {
212
228
  app,
213
229
  appStateStore,
214
230
  connectionManager,
231
+ DEFAULT_HOST,
215
232
  DEFAULT_PORT,
216
233
  parsePortArgument,
217
234
  resolvePort,
@@ -0,0 +1,168 @@
1
+ const path = require("node:path");
2
+ const os = require("node:os");
3
+ const { execFile } = require("node:child_process");
4
+ const { promisify } = require("node:util");
5
+ const { AppError } = require("../utils/errors");
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const DEFAULT_DATABASE_FILENAME = "new-database.sqlite";
9
+
10
+ function escapePowerShellSingleQuotedString(value) {
11
+ return String(value).replaceAll("'", "''");
12
+ }
13
+
14
+ function buildDialogAttempts({ platform = process.platform, homeDirectory = os.homedir() } = {}) {
15
+ const defaultPath = path.join(homeDirectory, DEFAULT_DATABASE_FILENAME);
16
+
17
+ if (platform === "darwin") {
18
+ return [
19
+ {
20
+ command: "osascript",
21
+ args: [
22
+ "-e",
23
+ "on run argv",
24
+ "-e",
25
+ 'set selectedFile to choose file name with prompt "Create SQLite Database" default location POSIX file (item 1 of argv) default name (item 2 of argv)',
26
+ "-e",
27
+ "return POSIX path of selectedFile",
28
+ "-e",
29
+ "end run",
30
+ homeDirectory,
31
+ DEFAULT_DATABASE_FILENAME,
32
+ ],
33
+ cancelledExitCodes: new Set([1]),
34
+ cancelledErrorPattern: /user canceled|-128/i,
35
+ },
36
+ ];
37
+ }
38
+
39
+ if (platform === "win32") {
40
+ const initialDirectory = escapePowerShellSingleQuotedString(homeDirectory);
41
+ const script = [
42
+ "Add-Type -AssemblyName System.Windows.Forms",
43
+ "$dialog = New-Object System.Windows.Forms.SaveFileDialog",
44
+ "$dialog.Title = 'Create SQLite Database'",
45
+ "$dialog.Filter = 'SQLite databases (*.db;*.sqlite;*.sqlite3)|*.db;*.sqlite;*.sqlite3|All files (*.*)|*.*'",
46
+ "$dialog.DefaultExt = 'sqlite'",
47
+ "$dialog.AddExtension = $true",
48
+ `$dialog.InitialDirectory = '${initialDirectory}'`,
49
+ `$dialog.FileName = '${DEFAULT_DATABASE_FILENAME}'`,
50
+ "if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {",
51
+ " [Console]::Out.Write($dialog.FileName)",
52
+ "} else {",
53
+ " exit 2",
54
+ "}",
55
+ ].join("; ");
56
+
57
+ return [
58
+ {
59
+ command: "powershell.exe",
60
+ args: ["-NoProfile", "-STA", "-Command", script],
61
+ cancelledExitCodes: new Set([2]),
62
+ },
63
+ ];
64
+ }
65
+
66
+ return [
67
+ {
68
+ command: "zenity",
69
+ args: [
70
+ "--file-selection",
71
+ "--save",
72
+ "--title=Create SQLite Database",
73
+ `--filename=${defaultPath}`,
74
+ "--file-filter=SQLite databases | *.db *.sqlite *.sqlite3",
75
+ "--file-filter=All files | *",
76
+ ],
77
+ cancelledExitCodes: new Set([1]),
78
+ },
79
+ {
80
+ command: "kdialog",
81
+ args: [
82
+ "--getsavefilename",
83
+ defaultPath,
84
+ "SQLite databases (*.db *.sqlite *.sqlite3)",
85
+ "--title",
86
+ "Create SQLite Database",
87
+ ],
88
+ cancelledExitCodes: new Set([1]),
89
+ },
90
+ ];
91
+ }
92
+
93
+ function normalizeSelectedDatabasePath(value) {
94
+ const selectedPath = String(value ?? "").trim();
95
+
96
+ if (!selectedPath) {
97
+ return null;
98
+ }
99
+
100
+ return path.extname(selectedPath) ? selectedPath : `${selectedPath}.sqlite`;
101
+ }
102
+
103
+ function isMissingDialogCommand(error) {
104
+ return error?.code === "ENOENT";
105
+ }
106
+
107
+ function isCancelledDialog(error, attempt) {
108
+ if (!attempt.cancelledExitCodes.has(Number(error?.code))) {
109
+ return false;
110
+ }
111
+
112
+ if (!attempt.cancelledErrorPattern) {
113
+ return true;
114
+ }
115
+
116
+ return attempt.cancelledErrorPattern.test(`${error?.message ?? ""}\n${error?.stderr ?? ""}`);
117
+ }
118
+
119
+ class NativeFileDialogService {
120
+ constructor(options = {}) {
121
+ this.platform = options.platform ?? process.platform;
122
+ this.homeDirectory = options.homeDirectory ?? os.homedir();
123
+ this.executeFile = options.executeFile ?? execFileAsync;
124
+ }
125
+
126
+ async chooseCreateDatabasePath() {
127
+ const attempts = buildDialogAttempts({
128
+ platform: this.platform,
129
+ homeDirectory: this.homeDirectory,
130
+ });
131
+
132
+ for (const attempt of attempts) {
133
+ try {
134
+ const result = await this.executeFile(attempt.command, attempt.args, {
135
+ maxBuffer: 64 * 1024,
136
+ windowsHide: true,
137
+ });
138
+
139
+ return normalizeSelectedDatabasePath(result?.stdout);
140
+ } catch (error) {
141
+ if (isCancelledDialog(error, attempt)) {
142
+ return null;
143
+ }
144
+
145
+ if (isMissingDialogCommand(error)) {
146
+ continue;
147
+ }
148
+
149
+ throw new AppError("The native file dialog could not be opened.", 500, {
150
+ code: "NATIVE_FILE_DIALOG_FAILED",
151
+ details: { platform: this.platform },
152
+ });
153
+ }
154
+ }
155
+
156
+ throw new AppError("No supported native file dialog is available on this system.", 501, {
157
+ code: "NATIVE_FILE_DIALOG_UNAVAILABLE",
158
+ details: { platform: this.platform },
159
+ });
160
+ }
161
+ }
162
+
163
+ module.exports = {
164
+ DEFAULT_DATABASE_FILENAME,
165
+ NativeFileDialogService,
166
+ buildDialogAttempts,
167
+ normalizeSelectedDatabasePath,
168
+ };
@@ -74,10 +74,6 @@ function formatPreviewValue(value) {
74
74
  }
75
75
 
76
76
  function isUnchangedSubmittedValue(currentValue, submittedValue) {
77
- if (currentValue === null && submittedValue === "") {
78
- return true;
79
- }
80
-
81
77
  return formatPreviewValue(currentValue) === formatPreviewValue(submittedValue);
82
78
  }
83
79
 
@@ -91,6 +91,8 @@ class ExportService {
91
91
  })
92
92
  );
93
93
  const result = this.sqlExecutor.execute(sql, {
94
+ blobMode: "full",
95
+ maxRows: null,
94
96
  persistHistory: false,
95
97
  requireReader: true,
96
98
  });
@@ -132,7 +134,9 @@ class ExportService {
132
134
  .filter(Boolean)
133
135
  .join(" ")
134
136
  );
135
- const rows = serializeRows(statement.all(...(filter?.params ?? [])));
137
+ const rows = serializeRows(statement.all(...(filter?.params ?? [])), {
138
+ blobMode: "full",
139
+ });
136
140
  const columns = statement.columns().map((column) => column.name);
137
141
  const content = renderExportContent({
138
142
  columns,
@@ -2,6 +2,45 @@ const { ValidationError, mapSqliteError } = require("../../utils/errors");
2
2
  const { serializeRows } = require("../../utils/sqliteTypes");
3
3
  const { getTableDetail } = require("./introspection");
4
4
 
5
+ const DEFAULT_RESULT_ROW_LIMIT = 5000;
6
+
7
+ function normalizeResultRowLimit(value) {
8
+ if (value === null || value === false) {
9
+ return null;
10
+ }
11
+
12
+ const normalized = value === undefined ? DEFAULT_RESULT_ROW_LIMIT : Number(value);
13
+
14
+ if (!Number.isInteger(normalized) || normalized < 1) {
15
+ throw new ValidationError("SQL result row limit must be a positive integer or null.");
16
+ }
17
+
18
+ return normalized;
19
+ }
20
+
21
+ function readResultRows(prepared, rowLimit) {
22
+ if (rowLimit === null) {
23
+ return {
24
+ rows: prepared.all(),
25
+ truncated: false,
26
+ };
27
+ }
28
+
29
+ const rows = [];
30
+ let truncated = false;
31
+
32
+ for (const row of prepared.iterate()) {
33
+ if (rows.length >= rowLimit) {
34
+ truncated = true;
35
+ break;
36
+ }
37
+
38
+ rows.push(row);
39
+ }
40
+
41
+ return { rows, truncated };
42
+ }
43
+
5
44
  function getSerializedMemoryBytes(value) {
6
45
  try {
7
46
  return Buffer.byteLength(JSON.stringify(value ?? null), "utf8");
@@ -365,6 +404,7 @@ class SqlExecutor {
365
404
  const db = this.connectionManager.getActiveDatabase();
366
405
  const connection = this.connectionManager.getActiveConnection();
367
406
  const statements = splitSqlStatements(sql);
407
+ const rowLimit = normalizeResultRowLimit(options.maxRows);
368
408
 
369
409
  if (statements.length === 0) {
370
410
  throw new ValidationError("No executable SQL statements were found.");
@@ -386,9 +426,11 @@ class SqlExecutor {
386
426
  }
387
427
 
388
428
  if (prepared.reader) {
389
- const rows = prepared.all();
429
+ const { rows, truncated } = readResultRows(prepared, rowLimit);
390
430
  const columnDefinitions = prepared.columns();
391
- const serializedRows = serializeRows(rows);
431
+ const serializedRows = serializeRows(rows, {
432
+ blobMode: options.blobMode,
433
+ });
392
434
  const editableResult = resolveEditableResult(db, columnDefinitions, serializedRows);
393
435
  const columns = columnDefinitions.map((column) => column.name);
394
436
  const result = {
@@ -397,6 +439,8 @@ class SqlExecutor {
397
439
  keyword,
398
440
  kind: "resultSet",
399
441
  rowCount: serializedRows.length,
442
+ truncated,
443
+ rowLimit,
400
444
  columns,
401
445
  rows: editableResult?.rows ?? serializedRows,
402
446
  editing: editableResult
@@ -463,6 +507,8 @@ class SqlExecutor {
463
507
  rows: lastResultSet?.rows ?? [],
464
508
  columns: lastResultSet?.columns ?? [],
465
509
  editing: lastResultSet?.editing ?? null,
510
+ truncated: Boolean(lastResultSet?.truncated),
511
+ rowLimit: lastResultSet ? lastResultSet.rowLimit : null,
466
512
  affectedRowCount: totalChanges,
467
513
  resultKind: lastResultSet ? "resultSet" : results.at(-1)?.kind ?? "unknown",
468
514
  };
@@ -490,6 +536,9 @@ class SqlExecutor {
490
536
  }
491
537
 
492
538
  module.exports = {
539
+ DEFAULT_RESULT_ROW_LIMIT,
493
540
  SqlExecutor,
541
+ normalizeResultRowLimit,
542
+ readResultRows,
494
543
  splitSqlStatements,
495
544
  };