sqlite-hub 0.17.2 → 1.1.0
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/README.md +143 -47
- package/bin/sqlite-hub.js +206 -444
- package/examples/api/queries.js +31 -0
- package/examples/api/rows.js +27 -0
- package/frontend/assets/mockups/charts_1_bars_1200.webp +0 -0
- package/frontend/assets/mockups/charts_2_pie_1200.webp +0 -0
- package/frontend/assets/mockups/charts_3_scatter_plot_1200.webp +0 -0
- package/frontend/assets/mockups/connections_1200.webp +0 -0
- package/frontend/assets/mockups/data_1_1200.webp +0 -0
- package/frontend/assets/mockups/data_2_row_editor_1200.webp +0 -0
- package/frontend/assets/mockups/documents_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_1_setup_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_2_tagging_queue_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_3_media_viewer_1200.webp +0 -0
- package/frontend/assets/mockups/overview_1200.webp +0 -0
- package/frontend/assets/mockups/settings_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_1_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_2_query_details_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_3_export_column_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_4_export_column_as_markdown_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_5_export_query_result_1200.webp +0 -0
- package/frontend/assets/mockups/structure_1_1200.webp +0 -0
- package/frontend/assets/mockups/structure_2_inspector_1200.webp +0 -0
- package/frontend/assets/mockups/table_designer_1_1200.webp +0 -0
- package/frontend/assets/mockups/table_designer_2_checks_1200.webp +0 -0
- package/frontend/js/api.js +25 -0
- package/frontend/js/app.js +263 -34
- package/frontend/js/components/formControls.js +38 -0
- package/frontend/js/components/modal.js +77 -21
- package/frontend/js/components/queryChartRenderer.js +28 -3
- package/frontend/js/components/queryEditor.js +20 -24
- package/frontend/js/components/queryHistoryDetail.js +1 -1
- package/frontend/js/components/queryHistoryHeader.js +48 -0
- package/frontend/js/components/queryHistoryList.js +132 -0
- package/frontend/js/components/queryHistoryPanel.js +72 -136
- package/frontend/js/components/structureGraph.js +699 -89
- package/frontend/js/components/tableDesignerEditor.js +3 -5
- package/frontend/js/store.js +188 -7
- package/frontend/js/utils/exportFilenames.js +3 -1
- package/frontend/js/views/charts.js +320 -169
- package/frontend/js/views/connections.js +59 -64
- package/frontend/js/views/data.js +12 -20
- package/frontend/js/views/editor.js +0 -2
- package/frontend/js/views/settings.js +219 -5
- package/frontend/js/views/structure.js +27 -13
- package/frontend/styles/components.css +155 -0
- package/frontend/styles/structure-graph.css +140 -35
- package/frontend/styles/tailwind.generated.css +2 -2
- package/frontend/styles/views.css +12 -6
- package/package.json +7 -6
- package/server/middleware/apiTokenAuth.js +30 -0
- package/server/routes/connections.js +17 -0
- package/server/routes/export.js +89 -22
- package/server/routes/externalApi.js +304 -0
- package/server/routes/settings.js +90 -21
- package/server/server.js +22 -1
- package/server/services/apiTokenService.js +101 -0
- package/server/services/appInfoService.js +215 -0
- package/server/services/databaseCommandService.js +443 -0
- package/server/services/nativeFileDialogService.js +93 -1
- package/server/services/sqlite/exportService.js +307 -22
- package/server/services/storage/appStateStore.js +113 -0
- package/server/utils/errors.js +7 -0
- package/tests/api-token-auth.test.js +236 -0
- package/tests/cli-args.test.js +16 -3
- package/tests/cli-service-delegation.test.js +43 -0
- package/tests/connections-file-dialog-route.test.js +43 -0
- package/tests/copy-column-modal.test.js +34 -0
- package/tests/database-command-service.test.js +139 -0
- package/tests/export-blob.test.js +54 -1
- package/tests/export-filenames.test.js +4 -0
- package/tests/form-controls.test.js +34 -0
- package/tests/native-file-dialog.test.js +27 -0
- package/tests/settings-api-tokens-route.test.js +97 -0
- package/tests/settings-metadata.test.js +99 -1
- package/tests/settings-view.test.js +75 -0
- package/frontend/assets/mockups/connections.png +0 -0
- package/frontend/assets/mockups/data.png +0 -0
- package/frontend/assets/mockups/data_row_editor.png +0 -0
- package/frontend/assets/mockups/home.png +0 -0
- package/frontend/assets/mockups/sql_editor.png +0 -0
- package/frontend/assets/mockups/sql_editor_croped.png +0 -0
- package/frontend/assets/mockups/sql_editor_querydetail.png +0 -0
- package/frontend/assets/mockups/structure.png +0 -0
- package/frontend/assets/mockups/structure_inspector.png +0 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const express = require("express");
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const os = require("node:os");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const test = require("node:test");
|
|
7
|
+
|
|
8
|
+
const { createExternalApiRouter } = require("../server/routes/externalApi");
|
|
9
|
+
const { ApiTokenService } = require("../server/services/apiTokenService");
|
|
10
|
+
const { AppStateStore } = require("../server/services/storage/appStateStore");
|
|
11
|
+
const { ReadOnlyError } = require("../server/utils/errors");
|
|
12
|
+
const { errorMiddleware } = require("../server/utils/errors");
|
|
13
|
+
|
|
14
|
+
function createConnection(id, label, databasePath) {
|
|
15
|
+
return {
|
|
16
|
+
id,
|
|
17
|
+
label,
|
|
18
|
+
path: databasePath,
|
|
19
|
+
lastOpenedAt: new Date().toISOString(),
|
|
20
|
+
lastModifiedAt: new Date().toISOString(),
|
|
21
|
+
sizeBytes: 0,
|
|
22
|
+
readOnly: false,
|
|
23
|
+
logoPath: null,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function startApi(t) {
|
|
28
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-hub-api-token-"));
|
|
29
|
+
const store = new AppStateStore(path.join(directory, "state.db"));
|
|
30
|
+
const databaseA = createConnection("db-a", "Database A", path.join(directory, "a.db"));
|
|
31
|
+
const databaseB = createConnection("db-b", "Database B", path.join(directory, "b.db"));
|
|
32
|
+
|
|
33
|
+
store.upsertRecentConnection(databaseA);
|
|
34
|
+
store.upsertRecentConnection(databaseB, { makeActive: false });
|
|
35
|
+
|
|
36
|
+
const tokenService = new ApiTokenService({ appStateStore: store });
|
|
37
|
+
const serviceCalls = [];
|
|
38
|
+
const databaseService = {
|
|
39
|
+
listTables(databaseId) {
|
|
40
|
+
serviceCalls.push(databaseId);
|
|
41
|
+
return [{ name: "companies" }];
|
|
42
|
+
},
|
|
43
|
+
executeRawQuery(databaseId, sql, options = {}) {
|
|
44
|
+
serviceCalls.push(`${databaseId}:query:${sql}:${options.storeName ?? ""}`);
|
|
45
|
+
if (sql === "READONLY") {
|
|
46
|
+
throw new ReadOnlyError("Cannot execute raw SQL against a read-only database.");
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
result: {
|
|
50
|
+
sql,
|
|
51
|
+
statementCount: 1,
|
|
52
|
+
statements: [],
|
|
53
|
+
rows: [],
|
|
54
|
+
columns: [],
|
|
55
|
+
affectedRowCount: 0,
|
|
56
|
+
resultKind: "unknown",
|
|
57
|
+
timingMs: 2,
|
|
58
|
+
historyId: 42,
|
|
59
|
+
storedQuery: options.storeName
|
|
60
|
+
? {
|
|
61
|
+
id: 42,
|
|
62
|
+
title: options.storeName,
|
|
63
|
+
isSaved: true,
|
|
64
|
+
}
|
|
65
|
+
: null,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const app = express();
|
|
71
|
+
|
|
72
|
+
app.use(express.json());
|
|
73
|
+
app.use(
|
|
74
|
+
"/api/v1",
|
|
75
|
+
createExternalApiRouter({
|
|
76
|
+
databaseService,
|
|
77
|
+
tokenService,
|
|
78
|
+
appInfoService: async ({ port, url }) => ({
|
|
79
|
+
packageName: "sqlite-hub",
|
|
80
|
+
appVersion: "1.0.1",
|
|
81
|
+
sqliteVersion: "3.50.0",
|
|
82
|
+
port,
|
|
83
|
+
url,
|
|
84
|
+
versionCheck: {
|
|
85
|
+
packageName: "sqlite-hub",
|
|
86
|
+
currentVersion: "1.0.1",
|
|
87
|
+
latestVersion: "1.0.1",
|
|
88
|
+
updateAvailable: false,
|
|
89
|
+
checkedAt: "2026-06-20T10:00:00.000Z",
|
|
90
|
+
source: "npm",
|
|
91
|
+
releaseUrl: "https://www.npmjs.com/package/sqlite-hub/v/1.0.1",
|
|
92
|
+
status: "current",
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
})
|
|
96
|
+
);
|
|
97
|
+
app.use(errorMiddleware);
|
|
98
|
+
|
|
99
|
+
const server = await new Promise((resolve) => {
|
|
100
|
+
const listener = app.listen(0, "127.0.0.1", () => resolve(listener));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
t.after(async () => {
|
|
104
|
+
await new Promise((resolve, reject) => {
|
|
105
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
106
|
+
});
|
|
107
|
+
store.db.close();
|
|
108
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
baseUrl: `http://127.0.0.1:${server.address().port}/api/v1`,
|
|
113
|
+
databaseA,
|
|
114
|
+
databaseB,
|
|
115
|
+
serviceCalls,
|
|
116
|
+
store,
|
|
117
|
+
tokenService,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
test("tokens are hashed, deletable, and isolated per database", async (t) => {
|
|
122
|
+
const fixture = await startApi(t);
|
|
123
|
+
const created = fixture.tokenService.createToken(fixture.databaseA.id, "Automation");
|
|
124
|
+
|
|
125
|
+
assert.match(created.token, /^shub_[A-Za-z0-9_-]+$/);
|
|
126
|
+
assert.equal(fixture.tokenService.listTokens(fixture.databaseA.id)[0].token, undefined);
|
|
127
|
+
|
|
128
|
+
const stored = fixture.store.db
|
|
129
|
+
.prepare("SELECT token_hash, token_prefix FROM api_tokens WHERE id = ?")
|
|
130
|
+
.get(created.id);
|
|
131
|
+
|
|
132
|
+
assert.notEqual(stored.token_hash, created.token);
|
|
133
|
+
assert.equal(stored.token_prefix, created.tokenPrefix);
|
|
134
|
+
assert.equal(fixture.tokenService.authenticate(fixture.databaseA.id, created.token).id, created.id);
|
|
135
|
+
assert.throws(
|
|
136
|
+
() => fixture.tokenService.authenticate(fixture.databaseB.id, created.token),
|
|
137
|
+
/invalid for this database/
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const validResponse = await fetch(
|
|
141
|
+
`${fixture.baseUrl}/databases/${fixture.databaseA.id}/tables`,
|
|
142
|
+
{ headers: { Authorization: `Bearer ${created.token}` } }
|
|
143
|
+
);
|
|
144
|
+
const validPayload = await validResponse.json();
|
|
145
|
+
|
|
146
|
+
assert.equal(validResponse.status, 200);
|
|
147
|
+
assert.deepEqual(validPayload.data.items, [{ name: "companies" }]);
|
|
148
|
+
assert.deepEqual(fixture.serviceCalls, [fixture.databaseA.id]);
|
|
149
|
+
|
|
150
|
+
const invalidResponse = await fetch(
|
|
151
|
+
`${fixture.baseUrl}/databases/${fixture.databaseA.id}/tables`,
|
|
152
|
+
{ headers: { Authorization: "Bearer invalid" } }
|
|
153
|
+
);
|
|
154
|
+
assert.equal(invalidResponse.status, 401);
|
|
155
|
+
|
|
156
|
+
const missingResponse = await fetch(
|
|
157
|
+
`${fixture.baseUrl}/databases/${fixture.databaseA.id}/tables`
|
|
158
|
+
);
|
|
159
|
+
assert.equal(missingResponse.status, 401);
|
|
160
|
+
|
|
161
|
+
const wrongDatabaseResponse = await fetch(
|
|
162
|
+
`${fixture.baseUrl}/databases/${fixture.databaseB.id}/tables`,
|
|
163
|
+
{ headers: { Authorization: `Bearer ${created.token}` } }
|
|
164
|
+
);
|
|
165
|
+
assert.equal(wrongDatabaseResponse.status, 401);
|
|
166
|
+
assert.deepEqual(fixture.serviceCalls, [fixture.databaseA.id]);
|
|
167
|
+
|
|
168
|
+
fixture.tokenService.deleteToken(fixture.databaseA.id, created.id);
|
|
169
|
+
assert.equal(fixture.tokenService.listTokens(fixture.databaseA.id).length, 0);
|
|
170
|
+
assert.throws(
|
|
171
|
+
() => fixture.tokenService.authenticate(fixture.databaseA.id, created.token),
|
|
172
|
+
/invalid for this database/
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("public API info returns app and version status without a token", async (t) => {
|
|
177
|
+
const fixture = await startApi(t);
|
|
178
|
+
const response = await fetch(`${fixture.baseUrl}/info`);
|
|
179
|
+
const payload = await response.json();
|
|
180
|
+
|
|
181
|
+
assert.equal(response.status, 200);
|
|
182
|
+
assert.equal(payload.data.packageName, "sqlite-hub");
|
|
183
|
+
assert.equal(payload.data.appVersion, "1.0.1");
|
|
184
|
+
assert.equal(payload.data.sqliteVersion, "3.50.0");
|
|
185
|
+
assert.equal(payload.data.versionCheck.status, "current");
|
|
186
|
+
assert.equal(payload.data.versionCheck.updateAvailable, false);
|
|
187
|
+
assert.match(payload.data.url, /^http:\/\/127\.0\.0\.1:\d+$/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("query API executes raw SQL with a database token", async (t) => {
|
|
191
|
+
const fixture = await startApi(t);
|
|
192
|
+
const created = fixture.tokenService.createToken(fixture.databaseA.id, "Automation");
|
|
193
|
+
const response = await fetch(`${fixture.baseUrl}/query`, {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers: {
|
|
196
|
+
Authorization: `Bearer ${created.token}`,
|
|
197
|
+
"Content-Type": "application/json",
|
|
198
|
+
},
|
|
199
|
+
body: JSON.stringify({
|
|
200
|
+
databaseId: fixture.databaseA.id,
|
|
201
|
+
sql: "SELECT 1",
|
|
202
|
+
store: "Stored API Query",
|
|
203
|
+
}),
|
|
204
|
+
});
|
|
205
|
+
const payload = await response.json();
|
|
206
|
+
|
|
207
|
+
assert.equal(response.status, 200);
|
|
208
|
+
assert.equal(payload.data.sql, "SELECT 1");
|
|
209
|
+
assert.equal(payload.data.historyId, 42);
|
|
210
|
+
assert.equal(payload.data.storedQuery.title, "Stored API Query");
|
|
211
|
+
assert.equal(payload.metadata.stored, true);
|
|
212
|
+
assert.equal(payload.metadata.databaseId, fixture.databaseA.id);
|
|
213
|
+
assert.deepEqual(fixture.serviceCalls, [
|
|
214
|
+
`${fixture.databaseA.id}:query:SELECT 1:Stored API Query`,
|
|
215
|
+
]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("query API rejects read-only raw SQL execution", async (t) => {
|
|
219
|
+
const fixture = await startApi(t);
|
|
220
|
+
const created = fixture.tokenService.createToken(fixture.databaseA.id, "Automation");
|
|
221
|
+
const response = await fetch(`${fixture.baseUrl}/query`, {
|
|
222
|
+
method: "POST",
|
|
223
|
+
headers: {
|
|
224
|
+
Authorization: `Bearer ${created.token}`,
|
|
225
|
+
"Content-Type": "application/json",
|
|
226
|
+
},
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
databaseId: fixture.databaseA.id,
|
|
229
|
+
sql: "READONLY",
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
const payload = await response.json();
|
|
233
|
+
|
|
234
|
+
assert.equal(response.status, 403);
|
|
235
|
+
assert.equal(payload.error.code, "SQLITE_READONLY");
|
|
236
|
+
});
|
package/tests/cli-args.test.js
CHANGED
|
@@ -31,6 +31,11 @@ test("parses database info commands with the new schema", () => {
|
|
|
31
31
|
assert.equal(parseCliArguments(["--database:db", "--lastopened"]).lastOpenedInfo, true);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
test("parses app info command and keeps config as a legacy alias", () => {
|
|
35
|
+
assert.equal(parseCliArguments(["--info"]).info, true);
|
|
36
|
+
assert.equal(parseCliArguments(["--config"]).info, true);
|
|
37
|
+
});
|
|
38
|
+
|
|
34
39
|
test("keeps old sqleditor aliases working", () => {
|
|
35
40
|
const listOptions = parseCliArguments(["--database:Unit-00", "--sqleditor"]);
|
|
36
41
|
const executeOptions = parseCliArguments(["--database:Unit-00", "--sqleditor:Saved Query"]);
|
|
@@ -50,8 +55,13 @@ test("keeps old database detail aliases working", () => {
|
|
|
50
55
|
assert.equal(tableOptions.tables, true);
|
|
51
56
|
});
|
|
52
57
|
|
|
53
|
-
test("parses query display and export commands", () => {
|
|
54
|
-
const
|
|
58
|
+
test("parses raw query, store name, saved query display, and export commands", () => {
|
|
59
|
+
const rawOptions = parseCliArguments([
|
|
60
|
+
"--database:db",
|
|
61
|
+
"--query:SELECT 1",
|
|
62
|
+
"--store:Stored Select",
|
|
63
|
+
]);
|
|
64
|
+
const showOptions = parseCliArguments(["--database:db", "--saved-query:Stock Winners"]);
|
|
55
65
|
const notesOptions = parseCliArguments(["--database:db", "--notes:Stock Winners"]);
|
|
56
66
|
const exportOptions = parseCliArguments([
|
|
57
67
|
"--database:db",
|
|
@@ -59,6 +69,8 @@ test("parses query display and export commands", () => {
|
|
|
59
69
|
"--format:md",
|
|
60
70
|
]);
|
|
61
71
|
|
|
72
|
+
assert.equal(rawOptions.rawQuery, "SELECT 1");
|
|
73
|
+
assert.equal(rawOptions.storeName, "Stored Select");
|
|
62
74
|
assert.equal(showOptions.showQuery, "Stock Winners");
|
|
63
75
|
assert.equal(notesOptions.showNotes, "Stock Winners");
|
|
64
76
|
assert.equal(exportOptions.exportTarget, "Stock Winners");
|
|
@@ -96,5 +108,6 @@ test("parses document commands", () => {
|
|
|
96
108
|
test("validates export formats", () => {
|
|
97
109
|
assert.equal(normalizeExportFormat("csv"), "csv");
|
|
98
110
|
assert.equal(normalizeExportFormat("TSV"), "tsv");
|
|
99
|
-
assert.
|
|
111
|
+
assert.equal(normalizeExportFormat("json"), "json");
|
|
112
|
+
assert.throws(() => normalizeExportFormat("xlsx"), /Unsupported export format/);
|
|
100
113
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const test = require("node:test");
|
|
3
|
+
|
|
4
|
+
const { main } = require("../bin/sqlite-hub");
|
|
5
|
+
|
|
6
|
+
test("CLI delegates database operations to the shared command service", async () => {
|
|
7
|
+
const calls = [];
|
|
8
|
+
const connection = {
|
|
9
|
+
id: "db-one",
|
|
10
|
+
label: "Database One",
|
|
11
|
+
};
|
|
12
|
+
const databaseService = {
|
|
13
|
+
getDatabase(reference) {
|
|
14
|
+
calls.push(["getDatabase", reference]);
|
|
15
|
+
return connection;
|
|
16
|
+
},
|
|
17
|
+
listDatabases() {
|
|
18
|
+
calls.push(["listDatabases"]);
|
|
19
|
+
return [connection];
|
|
20
|
+
},
|
|
21
|
+
listTables(reference) {
|
|
22
|
+
calls.push(["listTables", reference]);
|
|
23
|
+
return [{ name: "companies" }];
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
const output = [];
|
|
27
|
+
const originalLog = console.log;
|
|
28
|
+
|
|
29
|
+
console.log = (...values) => output.push(values.join(" "));
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
await main(["--database:Database One", "--tables"], { databaseService });
|
|
33
|
+
} finally {
|
|
34
|
+
console.log = originalLog;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
assert.deepEqual(calls, [
|
|
38
|
+
["listDatabases"],
|
|
39
|
+
["getDatabase", "Database One"],
|
|
40
|
+
["listTables", "db-one"],
|
|
41
|
+
]);
|
|
42
|
+
assert.match(output.join("\n"), /companies/);
|
|
43
|
+
});
|
|
@@ -15,6 +15,7 @@ test("connections route returns the path selected by the native dialog", async (
|
|
|
15
15
|
backupService: {},
|
|
16
16
|
nativeFileDialogService: {
|
|
17
17
|
chooseCreateDatabasePath: async () => "/tmp/new-database.sqlite",
|
|
18
|
+
chooseOpenDatabasePath: async () => "/tmp/existing-database.db",
|
|
18
19
|
},
|
|
19
20
|
})
|
|
20
21
|
);
|
|
@@ -44,3 +45,45 @@ test("connections route returns the path selected by the native dialog", async (
|
|
|
44
45
|
});
|
|
45
46
|
}
|
|
46
47
|
});
|
|
48
|
+
|
|
49
|
+
test("connections route returns the existing database selected by the native dialog", async () => {
|
|
50
|
+
const app = express();
|
|
51
|
+
app.use(express.json());
|
|
52
|
+
app.use(
|
|
53
|
+
"/api/connections",
|
|
54
|
+
createConnectionsRouter({
|
|
55
|
+
connectionManager: {},
|
|
56
|
+
importService: {},
|
|
57
|
+
backupService: {},
|
|
58
|
+
nativeFileDialogService: {
|
|
59
|
+
chooseCreateDatabasePath: async () => null,
|
|
60
|
+
chooseOpenDatabasePath: async () => "/tmp/existing-database.db",
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
app.use(errorMiddleware);
|
|
65
|
+
|
|
66
|
+
const server = await new Promise((resolve) => {
|
|
67
|
+
const listener = app.listen(0, "127.0.0.1", () => resolve(listener));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const address = server.address();
|
|
72
|
+
const response = await fetch(
|
|
73
|
+
`http://127.0.0.1:${address.port}/api/connections/choose-open-path`,
|
|
74
|
+
{ method: "POST" }
|
|
75
|
+
);
|
|
76
|
+
const payload = await response.json();
|
|
77
|
+
|
|
78
|
+
assert.equal(response.status, 200);
|
|
79
|
+
assert.equal(payload.success, true);
|
|
80
|
+
assert.deepEqual(payload.data, {
|
|
81
|
+
cancelled: false,
|
|
82
|
+
path: "/tmp/existing-database.db",
|
|
83
|
+
});
|
|
84
|
+
} finally {
|
|
85
|
+
await new Promise((resolve, reject) => {
|
|
86
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
});
|
|
@@ -95,3 +95,37 @@ test("create database modal offers a native path picker and manual fallback", as
|
|
|
95
95
|
assert.match(html, /name="path"/);
|
|
96
96
|
assert.match(html, /enter an absolute path manually/);
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
test("open database modal offers a native file picker and manual fallback", async () => {
|
|
100
|
+
const { renderOpenConnectionForm } = await loadModalModule();
|
|
101
|
+
const html = renderOpenConnectionForm({
|
|
102
|
+
kind: "open-connection",
|
|
103
|
+
error: null,
|
|
104
|
+
submitting: false,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
assert.match(html, /data-action="choose-open-database-path"/);
|
|
108
|
+
assert.match(html, /data-open-database-path/);
|
|
109
|
+
assert.match(html, /name="path"/);
|
|
110
|
+
assert.match(html, /enter an absolute path manually/);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("API token deletion uses the shared confirmation modal", async () => {
|
|
114
|
+
const { renderDeleteApiTokenForm } = await loadModalModule();
|
|
115
|
+
const html = renderDeleteApiTokenForm({
|
|
116
|
+
kind: "delete-api-token",
|
|
117
|
+
tokenId: "token-one",
|
|
118
|
+
tokenName: "Automation",
|
|
119
|
+
tokenPrefix: "shub_example",
|
|
120
|
+
databaseLabel: "Database One",
|
|
121
|
+
error: null,
|
|
122
|
+
submitting: false,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
assert.match(html, /Delete API token/);
|
|
126
|
+
assert.match(html, /Automation/);
|
|
127
|
+
assert.match(html, /Database One/);
|
|
128
|
+
assert.match(html, /shub_example\.\.\./);
|
|
129
|
+
assert.match(html, /data-form="delete-api-token-confirm"/);
|
|
130
|
+
assert.match(html, /Delete Token/);
|
|
131
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const os = require("node:os");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const test = require("node:test");
|
|
6
|
+
const Database = require("better-sqlite3");
|
|
7
|
+
|
|
8
|
+
const { DatabaseCommandService } = require("../server/services/databaseCommandService");
|
|
9
|
+
const { AppStateStore } = require("../server/services/storage/appStateStore");
|
|
10
|
+
|
|
11
|
+
function createFixture(t, options = {}) {
|
|
12
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-hub-command-service-"));
|
|
13
|
+
const databasePath = path.join(directory, "sample.db");
|
|
14
|
+
const targetDb = new Database(databasePath);
|
|
15
|
+
|
|
16
|
+
targetDb.exec(`
|
|
17
|
+
CREATE TABLE companies (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
|
|
18
|
+
INSERT INTO companies (name) VALUES ('Acme'), ('Globex');
|
|
19
|
+
`);
|
|
20
|
+
targetDb.close();
|
|
21
|
+
|
|
22
|
+
const store = new AppStateStore(path.join(directory, "state.db"));
|
|
23
|
+
const connection = {
|
|
24
|
+
id: "db-sample",
|
|
25
|
+
label: "Sample",
|
|
26
|
+
path: databasePath,
|
|
27
|
+
lastOpenedAt: new Date().toISOString(),
|
|
28
|
+
lastModifiedAt: new Date().toISOString(),
|
|
29
|
+
sizeBytes: fs.statSync(databasePath).size,
|
|
30
|
+
readOnly: Boolean(options.readOnly),
|
|
31
|
+
logoPath: null,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
store.upsertRecentConnection(connection);
|
|
35
|
+
store.db
|
|
36
|
+
.prepare(
|
|
37
|
+
`
|
|
38
|
+
INSERT INTO query_history (
|
|
39
|
+
database_key,
|
|
40
|
+
normalized_sql,
|
|
41
|
+
raw_sql,
|
|
42
|
+
title,
|
|
43
|
+
notes,
|
|
44
|
+
query_type,
|
|
45
|
+
tables_detected,
|
|
46
|
+
is_saved,
|
|
47
|
+
first_executed_at,
|
|
48
|
+
last_used_at
|
|
49
|
+
)
|
|
50
|
+
VALUES (?, ?, ?, ?, ?, 'select', '["companies"]', 1, ?, ?)
|
|
51
|
+
`
|
|
52
|
+
)
|
|
53
|
+
.run(
|
|
54
|
+
connection.id,
|
|
55
|
+
"select id, name from companies order by id",
|
|
56
|
+
"SELECT id, name FROM companies ORDER BY id",
|
|
57
|
+
"Company List",
|
|
58
|
+
"Used by CLI and API",
|
|
59
|
+
new Date().toISOString(),
|
|
60
|
+
new Date().toISOString()
|
|
61
|
+
);
|
|
62
|
+
store.createDatabaseDocument(connection.id, {
|
|
63
|
+
filename: "Readme.md",
|
|
64
|
+
content: "# Sample\n",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
t.after(() => {
|
|
68
|
+
store.db.close();
|
|
69
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
connection,
|
|
74
|
+
service: new DatabaseCommandService({ appStateStore: store }),
|
|
75
|
+
store,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test("database command service provides shared CLI and API operations", (t) => {
|
|
80
|
+
const { connection, service } = createFixture(t);
|
|
81
|
+
|
|
82
|
+
assert.equal(service.getDatabase("sample").id, connection.id);
|
|
83
|
+
assert.deepEqual(service.listTables(connection.id), [{ name: "companies" }]);
|
|
84
|
+
assert.equal(service.getTable(connection.id, "companies").rowCount, 2);
|
|
85
|
+
|
|
86
|
+
const row = service.getTableRow(connection.id, "companies", "1");
|
|
87
|
+
assert.deepEqual(row.data, { id: 1, name: "Acme" });
|
|
88
|
+
assert.equal(row.identity.kind, "primaryKey");
|
|
89
|
+
|
|
90
|
+
const queries = service.listSavedQueries(connection.id);
|
|
91
|
+
assert.equal(queries.total, 1);
|
|
92
|
+
assert.equal(service.getSavedQuery(connection.id, "Company List").notes, "Used by CLI and API");
|
|
93
|
+
|
|
94
|
+
const execution = service.executeSavedQuery(connection.id, "Company List");
|
|
95
|
+
assert.equal(execution.result.statements[0].rowCount, 2);
|
|
96
|
+
|
|
97
|
+
const exported = service.exportSavedQuery(connection.id, "Company List", "csv");
|
|
98
|
+
assert.equal(exported.result.rowCount, 2);
|
|
99
|
+
assert.match(exported.result.content, /Acme/);
|
|
100
|
+
|
|
101
|
+
assert.equal(service.listDocuments(connection.id).length, 1);
|
|
102
|
+
assert.equal(service.getDocument(connection.id, "Readme").content, "# Sample\n");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("raw query execution writes SQL Editor query history", (t) => {
|
|
106
|
+
const { connection, service, store } = createFixture(t);
|
|
107
|
+
const beforeCount = Number(
|
|
108
|
+
store.db.prepare("SELECT COUNT(*) AS count FROM query_history").get().count
|
|
109
|
+
);
|
|
110
|
+
const { result, storedQuery } = service.executeRawQuery(
|
|
111
|
+
connection.id,
|
|
112
|
+
"INSERT INTO companies (name) VALUES ('Initech')",
|
|
113
|
+
{ storeName: "Add Initech" }
|
|
114
|
+
);
|
|
115
|
+
const afterCount = Number(
|
|
116
|
+
store.db.prepare("SELECT COUNT(*) AS count FROM query_history").get().count
|
|
117
|
+
);
|
|
118
|
+
const historyRow = store.db
|
|
119
|
+
.prepare("SELECT raw_sql, query_type, title, is_saved FROM query_history WHERE id = ?")
|
|
120
|
+
.get(result.historyId);
|
|
121
|
+
|
|
122
|
+
assert.equal(result.affectedRowCount, 1);
|
|
123
|
+
assert.equal(afterCount, beforeCount + 1);
|
|
124
|
+
assert.equal(historyRow.raw_sql, "INSERT INTO companies (name) VALUES ('Initech')");
|
|
125
|
+
assert.equal(historyRow.query_type, "insert");
|
|
126
|
+
assert.equal(historyRow.title, "Add Initech");
|
|
127
|
+
assert.equal(historyRow.is_saved, 1);
|
|
128
|
+
assert.equal(storedQuery.title, "Add Initech");
|
|
129
|
+
assert.equal(storedQuery.isSaved, true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("raw query execution is blocked for read-only connections", (t) => {
|
|
133
|
+
const { connection, service } = createFixture(t, { readOnly: true });
|
|
134
|
+
|
|
135
|
+
assert.throws(
|
|
136
|
+
() => service.executeRawQuery(connection.id, "SELECT 1"),
|
|
137
|
+
/read-only database/
|
|
138
|
+
);
|
|
139
|
+
});
|
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
const Database = require("better-sqlite3");
|
|
2
2
|
const assert = require("node:assert/strict");
|
|
3
|
+
const parquet = require("parquetjs-lite");
|
|
3
4
|
const test = require("node:test");
|
|
4
5
|
const { ExportService } = require("../server/services/sqlite/exportService");
|
|
5
6
|
const { SqlExecutor } = require("../server/services/sqlite/sqlExecutor");
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
async function readParquetRows(buffer) {
|
|
9
|
+
const reader = await parquet.ParquetReader.openBuffer(buffer);
|
|
10
|
+
const cursor = reader.getCursor();
|
|
11
|
+
const rows = [];
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
let row;
|
|
15
|
+
|
|
16
|
+
while ((row = await cursor.next())) {
|
|
17
|
+
rows.push(row);
|
|
18
|
+
}
|
|
19
|
+
} finally {
|
|
20
|
+
await reader.close();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return rows;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test("query and table exports include complete BLOB data", async () => {
|
|
8
27
|
const db = new Database(":memory:");
|
|
9
28
|
const blob = Buffer.from(Array.from({ length: 100 }, (_, index) => index));
|
|
10
29
|
|
|
@@ -33,13 +52,47 @@ test("query and table exports include complete BLOB data", () => {
|
|
|
33
52
|
"SELECT payload FROM files ORDER BY id",
|
|
34
53
|
{ format: "csv" }
|
|
35
54
|
);
|
|
55
|
+
const queryJsonExport = exportService.exportQuery(
|
|
56
|
+
"SELECT payload FROM files ORDER BY id",
|
|
57
|
+
{ format: "json" }
|
|
58
|
+
);
|
|
36
59
|
const tableExport = exportService.exportTable("files", { format: "tsv" });
|
|
60
|
+
const jsonExport = exportService.exportTable("files", { format: "json" });
|
|
61
|
+
const queryParquetExport = await exportService.exportQueryDownload(
|
|
62
|
+
"SELECT id, payload FROM files ORDER BY id",
|
|
63
|
+
{ format: "parquet" }
|
|
64
|
+
);
|
|
65
|
+
const tableParquetExport = await exportService.exportTableDownload("files", { format: "parquet" });
|
|
37
66
|
|
|
38
67
|
for (const content of [queryExport.content, tableExport.content]) {
|
|
39
68
|
assert.equal(content.includes(expectedBase64), true);
|
|
40
69
|
assert.match(content, /""encoding"":""base64""/);
|
|
41
70
|
assert.doesNotMatch(content, /base64Preview|hexPreview/);
|
|
42
71
|
}
|
|
72
|
+
|
|
73
|
+
assert.equal(jsonExport.mimeType, "application/json; charset=utf-8");
|
|
74
|
+
assert.equal(jsonExport.filename, "files.json");
|
|
75
|
+
|
|
76
|
+
const [queryJsonRow] = JSON.parse(queryJsonExport.content);
|
|
77
|
+
const [jsonRow] = JSON.parse(jsonExport.content);
|
|
78
|
+
assert.equal(queryJsonExport.mimeType, "application/json; charset=utf-8");
|
|
79
|
+
assert.equal(queryJsonRow.payload.encoding, "base64");
|
|
80
|
+
assert.equal(queryJsonRow.payload.data, expectedBase64);
|
|
81
|
+
assert.equal(jsonRow.payload.encoding, "base64");
|
|
82
|
+
assert.equal(jsonRow.payload.data, expectedBase64);
|
|
83
|
+
assert.equal(jsonRow.payload.sizeBytes, blob.length);
|
|
84
|
+
assert.equal(jsonExport.content.includes("base64Preview"), false);
|
|
85
|
+
assert.equal(jsonExport.content.includes("hexPreview"), false);
|
|
86
|
+
|
|
87
|
+
assert.equal(queryParquetExport.mimeType, "application/vnd.apache.parquet");
|
|
88
|
+
assert.equal(queryParquetExport.filename.endsWith(".parquet"), true);
|
|
89
|
+
assert.equal(Buffer.isBuffer(queryParquetExport.content), true);
|
|
90
|
+
assert.equal(tableParquetExport.filename, "files.parquet");
|
|
91
|
+
|
|
92
|
+
const [queryParquetRow] = await readParquetRows(queryParquetExport.content);
|
|
93
|
+
const [tableParquetRow] = await readParquetRows(tableParquetExport.content);
|
|
94
|
+
assert.equal(queryParquetRow.payload.equals(blob), true);
|
|
95
|
+
assert.equal(tableParquetRow.payload.equals(blob), true);
|
|
43
96
|
} finally {
|
|
44
97
|
db.close();
|
|
45
98
|
}
|
|
@@ -24,6 +24,8 @@ test("text export filenames use the selected format extension", async () => {
|
|
|
24
24
|
|
|
25
25
|
assert.equal(buildTextExportFilename("query-results.csv", { format: "tsv" }), "query-results.tsv");
|
|
26
26
|
assert.equal(buildTextExportFilename("white_house_live_streams", { format: "md" }), "white_house_live_streams.md");
|
|
27
|
+
assert.equal(buildTextExportFilename("companies.tsv", { format: "json" }), "companies.json");
|
|
28
|
+
assert.equal(buildTextExportFilename("companies.json", { format: "parquet" }), "companies.parquet");
|
|
27
29
|
});
|
|
28
30
|
|
|
29
31
|
test("text export filenames sanitize unsafe names and fallback when empty", async () => {
|
|
@@ -31,4 +33,6 @@ test("text export filenames sanitize unsafe names and fallback when empty", asyn
|
|
|
31
33
|
|
|
32
34
|
assert.equal(buildTextExportFilename("", { format: "csv", fallback: "table" }), "table.csv");
|
|
33
35
|
assert.equal(buildTextExportFilename("../bad:name?.csv", { format: "csv", fallback: "table" }), "bad name.csv");
|
|
36
|
+
assert.equal(buildTextExportFilename("bad:name?.json", { format: "json", fallback: "table" }), "bad name.json");
|
|
37
|
+
assert.equal(buildTextExportFilename("bad:name?.parquet", { format: "parquet", fallback: "table" }), "bad name.parquet");
|
|
34
38
|
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const assert = require('node:assert/strict');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { pathToFileURL } = require('node:url');
|
|
4
|
+
const test = require('node:test');
|
|
5
|
+
|
|
6
|
+
let formControlsModulePromise = null;
|
|
7
|
+
|
|
8
|
+
function loadFormControlsModule() {
|
|
9
|
+
if (!formControlsModulePromise) {
|
|
10
|
+
formControlsModulePromise = import(
|
|
11
|
+
pathToFileURL(path.resolve(__dirname, '../frontend/js/components/formControls.js')).href
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return formControlsModulePromise;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('standard text input uses the shared application styling', async () => {
|
|
19
|
+
const { renderTextInput } = await loadFormControlsModule();
|
|
20
|
+
const markup = renderTextInput({
|
|
21
|
+
className: 'flex-1',
|
|
22
|
+
dataAttributes: { tokenName: true },
|
|
23
|
+
maxlength: 80,
|
|
24
|
+
placeholder: 'Token name',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
assert.match(markup, /control-input/);
|
|
28
|
+
assert.match(markup, /border-outline-variant\/20/);
|
|
29
|
+
assert.match(markup, /bg-surface-container-lowest/);
|
|
30
|
+
assert.match(markup, /placeholder:text-on-surface-variant\/35/);
|
|
31
|
+
assert.match(markup, /focus:border-primary-container/);
|
|
32
|
+
assert.match(markup, /data-token-name/);
|
|
33
|
+
assert.match(markup, /maxlength="80"/);
|
|
34
|
+
});
|