sqlite-hub 1.0.0 → 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 +47 -33
- package/bin/sqlite-hub.js +127 -47
- package/frontend/js/api.js +6 -0
- package/frontend/js/app.js +199 -34
- package/frontend/js/components/modal.js +17 -1
- 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 +73 -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 +78 -0
- 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 +1 -1
- package/frontend/styles/views.css +12 -6
- package/package.json +3 -2
- package/server/routes/export.js +89 -22
- package/server/routes/externalApi.js +84 -2
- package/server/routes/settings.js +37 -28
- package/server/services/appInfoService.js +215 -0
- package/server/services/databaseCommandService.js +50 -6
- package/server/services/sqlite/exportService.js +307 -22
- package/tests/api-token-auth.test.js +110 -1
- package/tests/cli-args.test.js +16 -3
- package/tests/database-command-service.test.js +39 -2
- package/tests/export-blob.test.js +54 -1
- package/tests/export-filenames.test.js +4 -0
- package/tests/settings-api-tokens-route.test.js +22 -1
- package/tests/settings-metadata.test.js +99 -1
- package/tests/settings-view.test.js +28 -0
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
const { PassThrough } = require("node:stream");
|
|
2
|
+
const parquet = require("parquetjs-lite");
|
|
1
3
|
const { quoteIdentifier } = require("../../utils/identifier");
|
|
2
4
|
const { serializeRows } = require("../../utils/sqliteTypes");
|
|
5
|
+
const { ValidationError } = require("../../utils/errors");
|
|
3
6
|
const {
|
|
4
7
|
rowsToCsv,
|
|
5
8
|
rowsToDelimitedText,
|
|
@@ -41,6 +44,14 @@ const EXPORT_FORMATS = {
|
|
|
41
44
|
extension: "md",
|
|
42
45
|
mimeType: "text/markdown; charset=utf-8",
|
|
43
46
|
},
|
|
47
|
+
json: {
|
|
48
|
+
extension: "json",
|
|
49
|
+
mimeType: "application/json; charset=utf-8",
|
|
50
|
+
},
|
|
51
|
+
parquet: {
|
|
52
|
+
extension: "parquet",
|
|
53
|
+
mimeType: "application/vnd.apache.parquet",
|
|
54
|
+
},
|
|
44
55
|
};
|
|
45
56
|
|
|
46
57
|
function normalizeExportFormat(format) {
|
|
@@ -54,6 +65,20 @@ function normalizeExportFormat(format) {
|
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
function renderExportContent({ columns, rows, format, csvDelimiter }) {
|
|
68
|
+
if (format === "parquet") {
|
|
69
|
+
throw new ValidationError("Parquet exports are binary and must use a download endpoint.");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (format === "json") {
|
|
73
|
+
return JSON.stringify(
|
|
74
|
+
rows.map((row) =>
|
|
75
|
+
Object.fromEntries(columns.map((column) => [column, row[column]]))
|
|
76
|
+
),
|
|
77
|
+
null,
|
|
78
|
+
2
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
57
82
|
if (format === "tsv") {
|
|
58
83
|
return rowsToDelimitedText({ columns, rows, delimiter: "\t" });
|
|
59
84
|
}
|
|
@@ -65,6 +90,206 @@ function renderExportContent({ columns, rows, format, csvDelimiter }) {
|
|
|
65
90
|
return rowsToCsv({ columns, rows, delimiter: csvDelimiter });
|
|
66
91
|
}
|
|
67
92
|
|
|
93
|
+
function isSerializedBlob(value) {
|
|
94
|
+
return (
|
|
95
|
+
value &&
|
|
96
|
+
typeof value === "object" &&
|
|
97
|
+
value.__type === "blob" &&
|
|
98
|
+
typeof value.data === "string"
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getParquetValueKind(value) {
|
|
103
|
+
if (value === null || value === undefined) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array || isSerializedBlob(value)) {
|
|
108
|
+
return "blob";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (typeof value === "boolean") {
|
|
112
|
+
return "boolean";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
116
|
+
return Number.isInteger(value) ? "integer" : "number";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return "string";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function inferParquetColumnType(rows, column) {
|
|
123
|
+
const kinds = new Set();
|
|
124
|
+
|
|
125
|
+
for (const row of rows) {
|
|
126
|
+
const kind = getParquetValueKind(row?.[column]);
|
|
127
|
+
|
|
128
|
+
if (kind) {
|
|
129
|
+
kinds.add(kind);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (kinds.size === 0) {
|
|
134
|
+
return "UTF8";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (kinds.size === 1 && kinds.has("blob")) {
|
|
138
|
+
return "BYTE_ARRAY";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (kinds.size === 1 && kinds.has("boolean")) {
|
|
142
|
+
return "BOOLEAN";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (kinds.size === 1 && kinds.has("integer")) {
|
|
146
|
+
return "INT64";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (
|
|
150
|
+
(kinds.size === 1 && kinds.has("number")) ||
|
|
151
|
+
(kinds.size === 2 && kinds.has("integer") && kinds.has("number"))
|
|
152
|
+
) {
|
|
153
|
+
return "DOUBLE";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return "UTF8";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function stringifyParquetValue(value) {
|
|
160
|
+
if (value === null || value === undefined) {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (typeof value === "string") {
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (Buffer.isBuffer(value)) {
|
|
169
|
+
return value.toString("base64");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (value instanceof Uint8Array) {
|
|
173
|
+
return Buffer.from(value).toString("base64");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (typeof value === "object") {
|
|
177
|
+
return JSON.stringify(value);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return String(value);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function normalizeParquetBlob(value) {
|
|
184
|
+
if (value === null || value === undefined) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (Buffer.isBuffer(value)) {
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (value instanceof Uint8Array) {
|
|
193
|
+
return Buffer.from(value);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (isSerializedBlob(value)) {
|
|
197
|
+
return Buffer.from(value.data, value.encoding === "hex" ? "hex" : "base64");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return Buffer.from(stringifyParquetValue(value) ?? "", "utf8");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeParquetValue(value, type) {
|
|
204
|
+
if (value === null || value === undefined) {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (type === "BYTE_ARRAY") {
|
|
209
|
+
return normalizeParquetBlob(value);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (type === "BOOLEAN") {
|
|
213
|
+
return Boolean(value);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (type === "INT64" || type === "DOUBLE") {
|
|
217
|
+
return Number(value);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return stringifyParquetValue(value);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function createParquetSchema(columns, rows) {
|
|
224
|
+
return new parquet.ParquetSchema(
|
|
225
|
+
Object.fromEntries(
|
|
226
|
+
columns.map((column) => [
|
|
227
|
+
column,
|
|
228
|
+
{
|
|
229
|
+
type: inferParquetColumnType(rows, column),
|
|
230
|
+
optional: true,
|
|
231
|
+
},
|
|
232
|
+
])
|
|
233
|
+
)
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function rowsToParquetBuffer({ columns, rows }) {
|
|
238
|
+
const schema = createParquetSchema(columns, rows);
|
|
239
|
+
const output = new PassThrough();
|
|
240
|
+
const chunks = [];
|
|
241
|
+
const finished = new Promise((resolve, reject) => {
|
|
242
|
+
output.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
243
|
+
output.on("end", resolve);
|
|
244
|
+
output.on("error", reject);
|
|
245
|
+
});
|
|
246
|
+
const writer = await parquet.ParquetWriter.openStream(schema, output, {
|
|
247
|
+
useDataPageV2: false,
|
|
248
|
+
});
|
|
249
|
+
const columnTypes = Object.fromEntries(
|
|
250
|
+
columns.map((column) => [column, schema.schema[column].type])
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
for (const row of rows) {
|
|
254
|
+
const parquetRow = {};
|
|
255
|
+
|
|
256
|
+
for (const column of columns) {
|
|
257
|
+
const value = normalizeParquetValue(row?.[column], columnTypes[column]);
|
|
258
|
+
|
|
259
|
+
if (value !== undefined) {
|
|
260
|
+
parquetRow[column] = value;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
await writer.appendRow(parquetRow);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await writer.close();
|
|
268
|
+
await finished;
|
|
269
|
+
|
|
270
|
+
return Buffer.concat(chunks);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function renderDownloadContent({ columns, rows, format, csvDelimiter }) {
|
|
274
|
+
if (format === "parquet") {
|
|
275
|
+
return rowsToParquetBuffer({ columns, rows });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return renderExportContent({ columns, rows, format, csvDelimiter });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function buildExportResult({ filenameBase, formatConfig, content, format, columns, rowCount }) {
|
|
282
|
+
return {
|
|
283
|
+
filename: `${filenameBase}.${formatConfig.extension}`,
|
|
284
|
+
content,
|
|
285
|
+
csv: format === "csv" ? content : undefined,
|
|
286
|
+
format,
|
|
287
|
+
mimeType: formatConfig.mimeType,
|
|
288
|
+
columns,
|
|
289
|
+
rowCount,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
68
293
|
class ExportService {
|
|
69
294
|
constructor({ appStateStore, connectionManager, sqlExecutor }) {
|
|
70
295
|
this.appStateStore = appStateStore;
|
|
@@ -76,7 +301,7 @@ class ExportService {
|
|
|
76
301
|
return this.appStateStore.getSettings().csvDelimiter || ",";
|
|
77
302
|
}
|
|
78
303
|
|
|
79
|
-
|
|
304
|
+
buildQueryExportData(sql, options = {}) {
|
|
80
305
|
const format = normalizeExportFormat(options.format);
|
|
81
306
|
const formatConfig = EXPORT_FORMATS[format];
|
|
82
307
|
const activeConnection = this.connectionManager.getActiveConnection();
|
|
@@ -96,25 +321,55 @@ class ExportService {
|
|
|
96
321
|
persistHistory: false,
|
|
97
322
|
requireReader: true,
|
|
98
323
|
});
|
|
99
|
-
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
filenameBase,
|
|
327
|
+
format,
|
|
328
|
+
formatConfig,
|
|
100
329
|
columns: result.columns,
|
|
101
330
|
rows: result.rows,
|
|
102
|
-
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
exportQuery(sql, options = {}) {
|
|
335
|
+
const exportData = this.buildQueryExportData(sql, options);
|
|
336
|
+
const content = renderExportContent({
|
|
337
|
+
columns: exportData.columns,
|
|
338
|
+
rows: exportData.rows,
|
|
339
|
+
format: exportData.format,
|
|
103
340
|
csvDelimiter: this.getDelimiter(),
|
|
104
341
|
});
|
|
105
342
|
|
|
106
|
-
return {
|
|
107
|
-
|
|
343
|
+
return buildExportResult({
|
|
344
|
+
filenameBase: exportData.filenameBase,
|
|
345
|
+
formatConfig: exportData.formatConfig,
|
|
108
346
|
content,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
rowCount: result.rows.length,
|
|
114
|
-
};
|
|
347
|
+
format: exportData.format,
|
|
348
|
+
columns: exportData.columns,
|
|
349
|
+
rowCount: exportData.rows.length,
|
|
350
|
+
});
|
|
115
351
|
}
|
|
116
352
|
|
|
117
|
-
|
|
353
|
+
async exportQueryDownload(sql, options = {}) {
|
|
354
|
+
const exportData = this.buildQueryExportData(sql, options);
|
|
355
|
+
const content = await renderDownloadContent({
|
|
356
|
+
columns: exportData.columns,
|
|
357
|
+
rows: exportData.rows,
|
|
358
|
+
format: exportData.format,
|
|
359
|
+
csvDelimiter: this.getDelimiter(),
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
return buildExportResult({
|
|
363
|
+
filenameBase: exportData.filenameBase,
|
|
364
|
+
formatConfig: exportData.formatConfig,
|
|
365
|
+
content,
|
|
366
|
+
format: exportData.format,
|
|
367
|
+
columns: exportData.columns,
|
|
368
|
+
rowCount: exportData.rows.length,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
buildTableExportData(tableName, options = {}) {
|
|
118
373
|
const format = normalizeExportFormat(options.format);
|
|
119
374
|
const formatConfig = EXPORT_FORMATS[format];
|
|
120
375
|
const db = this.connectionManager.getActiveDatabase();
|
|
@@ -138,22 +393,52 @@ class ExportService {
|
|
|
138
393
|
blobMode: "full",
|
|
139
394
|
});
|
|
140
395
|
const columns = statement.columns().map((column) => column.name);
|
|
141
|
-
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
filenameBase: tableName,
|
|
399
|
+
format,
|
|
400
|
+
formatConfig,
|
|
142
401
|
columns,
|
|
143
402
|
rows,
|
|
144
|
-
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
exportTable(tableName, options = {}) {
|
|
407
|
+
const exportData = this.buildTableExportData(tableName, options);
|
|
408
|
+
const content = renderExportContent({
|
|
409
|
+
columns: exportData.columns,
|
|
410
|
+
rows: exportData.rows,
|
|
411
|
+
format: exportData.format,
|
|
145
412
|
csvDelimiter: this.getDelimiter(),
|
|
146
413
|
});
|
|
147
414
|
|
|
148
|
-
return {
|
|
149
|
-
|
|
415
|
+
return buildExportResult({
|
|
416
|
+
filenameBase: exportData.filenameBase,
|
|
417
|
+
formatConfig: exportData.formatConfig,
|
|
150
418
|
content,
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
419
|
+
format: exportData.format,
|
|
420
|
+
columns: exportData.columns,
|
|
421
|
+
rowCount: exportData.rows.length,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async exportTableDownload(tableName, options = {}) {
|
|
426
|
+
const exportData = this.buildTableExportData(tableName, options);
|
|
427
|
+
const content = await renderDownloadContent({
|
|
428
|
+
columns: exportData.columns,
|
|
429
|
+
rows: exportData.rows,
|
|
430
|
+
format: exportData.format,
|
|
431
|
+
csvDelimiter: this.getDelimiter(),
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
return buildExportResult({
|
|
435
|
+
filenameBase: exportData.filenameBase,
|
|
436
|
+
formatConfig: exportData.formatConfig,
|
|
437
|
+
content,
|
|
438
|
+
format: exportData.format,
|
|
439
|
+
columns: exportData.columns,
|
|
440
|
+
rowCount: exportData.rows.length,
|
|
441
|
+
});
|
|
157
442
|
}
|
|
158
443
|
}
|
|
159
444
|
|
|
@@ -8,6 +8,7 @@ const test = require("node:test");
|
|
|
8
8
|
const { createExternalApiRouter } = require("../server/routes/externalApi");
|
|
9
9
|
const { ApiTokenService } = require("../server/services/apiTokenService");
|
|
10
10
|
const { AppStateStore } = require("../server/services/storage/appStateStore");
|
|
11
|
+
const { ReadOnlyError } = require("../server/utils/errors");
|
|
11
12
|
const { errorMiddleware } = require("../server/utils/errors");
|
|
12
13
|
|
|
13
14
|
function createConnection(id, label, databasePath) {
|
|
@@ -39,13 +40,59 @@ async function startApi(t) {
|
|
|
39
40
|
serviceCalls.push(databaseId);
|
|
40
41
|
return [{ name: "companies" }];
|
|
41
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
|
+
},
|
|
42
69
|
};
|
|
43
70
|
const app = express();
|
|
44
71
|
|
|
45
72
|
app.use(express.json());
|
|
46
73
|
app.use(
|
|
47
74
|
"/api/v1",
|
|
48
|
-
createExternalApiRouter({
|
|
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
|
+
})
|
|
49
96
|
);
|
|
50
97
|
app.use(errorMiddleware);
|
|
51
98
|
|
|
@@ -125,3 +172,65 @@ test("tokens are hashed, deletable, and isolated per database", async (t) => {
|
|
|
125
172
|
/invalid for this database/
|
|
126
173
|
);
|
|
127
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
|
});
|
|
@@ -8,7 +8,7 @@ const Database = require("better-sqlite3");
|
|
|
8
8
|
const { DatabaseCommandService } = require("../server/services/databaseCommandService");
|
|
9
9
|
const { AppStateStore } = require("../server/services/storage/appStateStore");
|
|
10
10
|
|
|
11
|
-
function createFixture(t) {
|
|
11
|
+
function createFixture(t, options = {}) {
|
|
12
12
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-hub-command-service-"));
|
|
13
13
|
const databasePath = path.join(directory, "sample.db");
|
|
14
14
|
const targetDb = new Database(databasePath);
|
|
@@ -27,7 +27,7 @@ function createFixture(t) {
|
|
|
27
27
|
lastOpenedAt: new Date().toISOString(),
|
|
28
28
|
lastModifiedAt: new Date().toISOString(),
|
|
29
29
|
sizeBytes: fs.statSync(databasePath).size,
|
|
30
|
-
readOnly:
|
|
30
|
+
readOnly: Boolean(options.readOnly),
|
|
31
31
|
logoPath: null,
|
|
32
32
|
};
|
|
33
33
|
|
|
@@ -72,6 +72,7 @@ function createFixture(t) {
|
|
|
72
72
|
return {
|
|
73
73
|
connection,
|
|
74
74
|
service: new DatabaseCommandService({ appStateStore: store }),
|
|
75
|
+
store,
|
|
75
76
|
};
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -100,3 +101,39 @@ test("database command service provides shared CLI and API operations", (t) => {
|
|
|
100
101
|
assert.equal(service.listDocuments(connection.id).length, 1);
|
|
101
102
|
assert.equal(service.getDocument(connection.id, "Readme").content, "# Sample\n");
|
|
102
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
|
+
});
|