sqlite-hub 1.0.0 → 1.1.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.
Files changed (47) hide show
  1. package/README.md +47 -33
  2. package/bin/sqlite-hub.js +127 -47
  3. package/frontend/js/api.js +6 -0
  4. package/frontend/js/app.js +229 -44
  5. package/frontend/js/components/modal.js +17 -1
  6. package/frontend/js/components/queryChartRenderer.js +28 -3
  7. package/frontend/js/components/queryEditor.js +24 -26
  8. package/frontend/js/components/queryHistoryDetail.js +1 -1
  9. package/frontend/js/components/queryHistoryHeader.js +48 -0
  10. package/frontend/js/components/queryHistoryList.js +132 -0
  11. package/frontend/js/components/queryHistoryPanel.js +72 -136
  12. package/frontend/js/components/structureGraph.js +700 -89
  13. package/frontend/js/components/tableDesignerEditor.js +26 -47
  14. package/frontend/js/components/tableDesignerSidebar.js +7 -31
  15. package/frontend/js/components/tableDesignerSqlPreview.js +2 -1
  16. package/frontend/js/store.js +97 -8
  17. package/frontend/js/utils/exportFilenames.js +3 -1
  18. package/frontend/js/views/charts.js +326 -174
  19. package/frontend/js/views/connections.js +59 -64
  20. package/frontend/js/views/data.js +48 -54
  21. package/frontend/js/views/documents.js +62 -23
  22. package/frontend/js/views/editor.js +0 -2
  23. package/frontend/js/views/mediaTagging.js +6 -5
  24. package/frontend/js/views/settings.js +78 -0
  25. package/frontend/js/views/structure.js +48 -32
  26. package/frontend/js/views/tableDesigner.js +45 -1
  27. package/frontend/styles/components.css +259 -54
  28. package/frontend/styles/structure-graph.css +150 -37
  29. package/frontend/styles/tailwind.generated.css +1 -1
  30. package/frontend/styles/tokens.css +2 -0
  31. package/frontend/styles/views.css +75 -38
  32. package/package.json +3 -2
  33. package/server/routes/export.js +89 -22
  34. package/server/routes/externalApi.js +84 -2
  35. package/server/routes/settings.js +37 -28
  36. package/server/services/appInfoService.js +215 -0
  37. package/server/services/databaseCommandService.js +50 -6
  38. package/server/services/sqlite/dataBrowserService.js +11 -3
  39. package/server/services/sqlite/exportService.js +307 -22
  40. package/tests/api-token-auth.test.js +110 -1
  41. package/tests/cli-args.test.js +16 -3
  42. package/tests/database-command-service.test.js +40 -3
  43. package/tests/export-blob.test.js +54 -1
  44. package/tests/export-filenames.test.js +4 -0
  45. package/tests/settings-api-tokens-route.test.js +22 -1
  46. package/tests/settings-metadata.test.js +99 -1
  47. package/tests/settings-view.test.js +28 -0
@@ -0,0 +1,215 @@
1
+ const Database = require("better-sqlite3");
2
+ const fs = require("node:fs");
3
+ const path = require("node:path");
4
+
5
+ const VERSION_CHECK_TIMEOUT_MS = 5000;
6
+
7
+ function readPackageMetadata() {
8
+ const packageJsonPath = path.resolve(__dirname, "..", "..", "package.json");
9
+ return JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
10
+ }
11
+
12
+ function readAppVersion() {
13
+ const packageJson = readPackageMetadata();
14
+ return packageJson.version ?? "0.0.0";
15
+ }
16
+
17
+ function readPackageName() {
18
+ const packageJson = readPackageMetadata();
19
+ return packageJson.name ?? "sqlite-hub";
20
+ }
21
+
22
+ function parseSemver(value) {
23
+ const match = String(value ?? "")
24
+ .trim()
25
+ .replace(/^v/i, "")
26
+ .match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?/);
27
+
28
+ if (!match) {
29
+ return null;
30
+ }
31
+
32
+ return {
33
+ major: Number(match[1] ?? 0),
34
+ minor: Number(match[2] ?? 0),
35
+ patch: Number(match[3] ?? 0),
36
+ prerelease: match[4] ?? "",
37
+ };
38
+ }
39
+
40
+ function compareSemver(left, right) {
41
+ const leftVersion = parseSemver(left);
42
+ const rightVersion = parseSemver(right);
43
+
44
+ if (!leftVersion || !rightVersion) {
45
+ return 0;
46
+ }
47
+
48
+ for (const key of ["major", "minor", "patch"]) {
49
+ if (leftVersion[key] > rightVersion[key]) {
50
+ return 1;
51
+ }
52
+
53
+ if (leftVersion[key] < rightVersion[key]) {
54
+ return -1;
55
+ }
56
+ }
57
+
58
+ if (leftVersion.prerelease && !rightVersion.prerelease) {
59
+ return -1;
60
+ }
61
+
62
+ if (!leftVersion.prerelease && rightVersion.prerelease) {
63
+ return 1;
64
+ }
65
+
66
+ return leftVersion.prerelease.localeCompare(rightVersion.prerelease);
67
+ }
68
+
69
+ function isNewerVersion(candidateVersion, currentVersion) {
70
+ return compareSemver(candidateVersion, currentVersion) > 0;
71
+ }
72
+
73
+ function readSqliteVersion() {
74
+ const db = new Database(":memory:");
75
+
76
+ try {
77
+ return db.prepare("SELECT sqlite_version() AS version").get().version ?? "unknown";
78
+ } finally {
79
+ db.close();
80
+ }
81
+ }
82
+
83
+ function readSettingsMetadata() {
84
+ return {
85
+ appVersion: readAppVersion(),
86
+ sqliteVersion: readSqliteVersion(),
87
+ };
88
+ }
89
+
90
+ async function fetchJsonWithTimeout(url, options = {}) {
91
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
92
+
93
+ if (typeof fetchImpl !== "function") {
94
+ throw new Error("Fetch API is not available in this runtime.");
95
+ }
96
+
97
+ const controller = new AbortController();
98
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? VERSION_CHECK_TIMEOUT_MS);
99
+
100
+ try {
101
+ const response = await fetchImpl(url, {
102
+ headers: {
103
+ Accept: "application/json",
104
+ "User-Agent": "SQLite Hub version check",
105
+ },
106
+ signal: controller.signal,
107
+ });
108
+
109
+ if (!response.ok) {
110
+ throw new Error(`Registry responded with HTTP ${response.status}.`);
111
+ }
112
+
113
+ return response.json();
114
+ } finally {
115
+ clearTimeout(timeout);
116
+ }
117
+ }
118
+
119
+ async function checkLatestAppVersion(options = {}) {
120
+ const packageName = options.packageName ?? readPackageName();
121
+ const currentVersion = options.currentVersion ?? readAppVersion();
122
+ const registryUrl =
123
+ options.registryUrl ??
124
+ `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`;
125
+ const payload = await fetchJsonWithTimeout(registryUrl, options);
126
+ const latestVersion = String(payload?.version ?? "").trim();
127
+
128
+ if (!latestVersion) {
129
+ throw new Error("Registry response did not include a version.");
130
+ }
131
+
132
+ return {
133
+ packageName,
134
+ currentVersion,
135
+ latestVersion,
136
+ updateAvailable: isNewerVersion(latestVersion, currentVersion),
137
+ checkedAt: new Date().toISOString(),
138
+ source: "npm",
139
+ releaseUrl: `https://www.npmjs.com/package/${packageName}/v/${latestVersion}`,
140
+ };
141
+ }
142
+
143
+ function normalizeVersionCheckStatus(versionCheck) {
144
+ if (!versionCheck || versionCheck.status === "unknown") {
145
+ return versionCheck ?? null;
146
+ }
147
+
148
+ if (
149
+ versionCheck.currentVersion &&
150
+ versionCheck.latestVersion &&
151
+ compareSemver(versionCheck.currentVersion, versionCheck.latestVersion) > 0
152
+ ) {
153
+ return {
154
+ ...versionCheck,
155
+ status: "ahead",
156
+ };
157
+ }
158
+
159
+ return {
160
+ ...versionCheck,
161
+ status: versionCheck.updateAvailable ? "update_available" : "current",
162
+ };
163
+ }
164
+
165
+ async function buildAppInfo(options = {}) {
166
+ const packageName = options.packageName ?? readPackageName();
167
+ const appVersion = options.currentVersion ?? readAppVersion();
168
+ const sqliteVersion = options.sqliteVersion ?? readSqliteVersion();
169
+ let versionCheck = null;
170
+
171
+ try {
172
+ versionCheck = await (options.versionCheckService ?? checkLatestAppVersion)({
173
+ ...options,
174
+ packageName,
175
+ currentVersion: appVersion,
176
+ });
177
+ versionCheck = normalizeVersionCheckStatus(versionCheck);
178
+ } catch (error) {
179
+ versionCheck = {
180
+ packageName,
181
+ currentVersion: appVersion,
182
+ latestVersion: null,
183
+ updateAvailable: null,
184
+ checkedAt: new Date().toISOString(),
185
+ source: "npm",
186
+ releaseUrl: null,
187
+ status: "unknown",
188
+ error: {
189
+ message: error.message,
190
+ },
191
+ };
192
+ }
193
+
194
+ return {
195
+ packageName,
196
+ appVersion,
197
+ sqliteVersion,
198
+ port: options.port ?? null,
199
+ url: options.url ?? null,
200
+ versionCheck,
201
+ };
202
+ }
203
+
204
+ module.exports = {
205
+ VERSION_CHECK_TIMEOUT_MS,
206
+ buildAppInfo,
207
+ checkLatestAppVersion,
208
+ compareSemver,
209
+ isNewerVersion,
210
+ readAppVersion,
211
+ readPackageMetadata,
212
+ readPackageName,
213
+ readSettingsMetadata,
214
+ readSqliteVersion,
215
+ };
@@ -1,5 +1,5 @@
1
1
  const path = require("node:path");
2
- const { NotFoundError, ValidationError } = require("../utils/errors");
2
+ const { NotFoundError, ReadOnlyError, ValidationError } = require("../utils/errors");
3
3
  const { ConnectionManager } = require("./sqlite/connectionManager");
4
4
  const { DataBrowserService } = require("./sqlite/dataBrowserService");
5
5
  const { ExportService } = require("./sqlite/exportService");
@@ -16,6 +16,11 @@ function normalizeLookupValue(value, label) {
16
16
  return normalized;
17
17
  }
18
18
 
19
+ function normalizeOptionalValue(value) {
20
+ const normalized = String(value ?? "").trim();
21
+ return normalized || null;
22
+ }
23
+
19
24
  function getQueryTitle(item) {
20
25
  return item?.title || item?.displayTitle || item?.previewSql || item?.rawSql || "(untitled query)";
21
26
  }
@@ -160,7 +165,7 @@ function buildRowJsonObject({ row, columns = [] } = {}) {
160
165
  class DatabaseCommandService {
161
166
  constructor({ appStateStore, runtimeFactory } = {}) {
162
167
  this.appStateStore = appStateStore;
163
- this.runtimeFactory = runtimeFactory ?? ((connection) => this.createReadOnlyRuntime(connection));
168
+ this.runtimeFactory = runtimeFactory ?? ((connection, options) => this.createRuntime(connection, options));
164
169
  }
165
170
 
166
171
  listDatabases() {
@@ -182,7 +187,7 @@ class DatabaseCommandService {
182
187
  return connection;
183
188
  }
184
189
 
185
- createReadOnlyRuntime(connection) {
190
+ createRuntime(connection, { readOnly = true } = {}) {
186
191
  const connectionManager = new ConnectionManager({ appStateStore: this.appStateStore });
187
192
 
188
193
  connectionManager.openConnection({
@@ -191,7 +196,7 @@ class DatabaseCommandService {
191
196
  id: connection.id,
192
197
  logoPath: connection.logoPath ?? null,
193
198
  makeActive: false,
194
- readOnly: true,
199
+ readOnly,
195
200
  });
196
201
 
197
202
  const sqlExecutor = new SqlExecutor({
@@ -215,9 +220,17 @@ class DatabaseCommandService {
215
220
  };
216
221
  }
217
222
 
218
- withDatabase(databaseReference, callback) {
223
+ createReadOnlyRuntime(connection) {
224
+ return this.createRuntime(connection, { readOnly: true });
225
+ }
226
+
227
+ createWritableRuntime(connection) {
228
+ return this.createRuntime(connection, { readOnly: false });
229
+ }
230
+
231
+ withDatabase(databaseReference, callback, options = {}) {
219
232
  const connection = this.getDatabase(databaseReference);
220
- const runtime = this.runtimeFactory(connection);
233
+ const runtime = this.runtimeFactory(connection, options);
221
234
 
222
235
  try {
223
236
  return callback({ connection, runtime });
@@ -322,6 +335,37 @@ class DatabaseCommandService {
322
335
  return { query, result };
323
336
  }
324
337
 
338
+ executeRawQuery(databaseReference, sql, options = {}) {
339
+ const connection = this.getDatabase(databaseReference);
340
+ const { name = null, storeName = null, ...executeOptions } = options;
341
+ const normalizedStoreName = normalizeOptionalValue(storeName ?? name);
342
+
343
+ if (connection.readOnly) {
344
+ throw new ReadOnlyError(`Cannot execute raw SQL against a read-only database: ${connection.label}`);
345
+ }
346
+
347
+ const result = this.withDatabase(
348
+ connection.id,
349
+ ({ runtime }) => runtime.sqlExecutor.execute(sql, executeOptions),
350
+ { readOnly: false }
351
+ );
352
+ let storedQuery = null;
353
+
354
+ if (normalizedStoreName && result.historyId) {
355
+ this.appStateStore.renameQuery(result.historyId, normalizedStoreName, connection.id);
356
+ storedQuery = this.appStateStore.toggleSaved(result.historyId, true, connection.id);
357
+ }
358
+
359
+ return {
360
+ connection,
361
+ result: {
362
+ ...result,
363
+ storedQuery,
364
+ },
365
+ storedQuery,
366
+ };
367
+ }
368
+
325
369
  exportSavedQuery(databaseReference, queryName, format = "csv") {
326
370
  const query = this.requireQuery(databaseReference, queryName);
327
371
  const result = this.withDatabase(databaseReference, ({ runtime }) =>
@@ -87,9 +87,17 @@ class DataBrowserService {
87
87
 
88
88
  return getRawStructureEntries(db)
89
89
  .filter((entry) => entry.type === "table")
90
- .map((entry) => ({
91
- name: entry.name,
92
- }));
90
+ .map((entry) => {
91
+ const columnCount = db
92
+ .prepare(`PRAGMA table_xinfo(${quoteIdentifier(entry.name)})`)
93
+ .all()
94
+ .filter((column) => Number(column.hidden ?? 0) === 0).length;
95
+
96
+ return {
97
+ name: entry.name,
98
+ columnCount,
99
+ };
100
+ });
93
101
  }
94
102
 
95
103
  getTableData(tableName, options = {}) {