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
|
@@ -90,6 +90,79 @@ function buildDialogAttempts({ platform = process.platform, homeDirectory = os.h
|
|
|
90
90
|
];
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
function buildOpenDialogAttempts({ platform = process.platform, homeDirectory = os.homedir() } = {}) {
|
|
94
|
+
if (platform === "darwin") {
|
|
95
|
+
return [
|
|
96
|
+
{
|
|
97
|
+
command: "osascript",
|
|
98
|
+
args: [
|
|
99
|
+
"-e",
|
|
100
|
+
"on run argv",
|
|
101
|
+
"-e",
|
|
102
|
+
'set selectedFile to choose file with prompt "Open SQLite Database" default location POSIX file (item 1 of argv)',
|
|
103
|
+
"-e",
|
|
104
|
+
"return POSIX path of selectedFile",
|
|
105
|
+
"-e",
|
|
106
|
+
"end run",
|
|
107
|
+
homeDirectory,
|
|
108
|
+
],
|
|
109
|
+
cancelledExitCodes: new Set([1]),
|
|
110
|
+
cancelledErrorPattern: /user canceled|-128/i,
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (platform === "win32") {
|
|
116
|
+
const initialDirectory = escapePowerShellSingleQuotedString(homeDirectory);
|
|
117
|
+
const script = [
|
|
118
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
119
|
+
"$dialog = New-Object System.Windows.Forms.OpenFileDialog",
|
|
120
|
+
"$dialog.Title = 'Open SQLite Database'",
|
|
121
|
+
"$dialog.Filter = 'SQLite databases (*.db;*.sqlite;*.sqlite3)|*.db;*.sqlite;*.sqlite3|All files (*.*)|*.*'",
|
|
122
|
+
"$dialog.CheckFileExists = $true",
|
|
123
|
+
`$dialog.InitialDirectory = '${initialDirectory}'`,
|
|
124
|
+
"if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {",
|
|
125
|
+
" [Console]::Out.Write($dialog.FileName)",
|
|
126
|
+
"} else {",
|
|
127
|
+
" exit 2",
|
|
128
|
+
"}",
|
|
129
|
+
].join("; ");
|
|
130
|
+
|
|
131
|
+
return [
|
|
132
|
+
{
|
|
133
|
+
command: "powershell.exe",
|
|
134
|
+
args: ["-NoProfile", "-STA", "-Command", script],
|
|
135
|
+
cancelledExitCodes: new Set([2]),
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return [
|
|
141
|
+
{
|
|
142
|
+
command: "zenity",
|
|
143
|
+
args: [
|
|
144
|
+
"--file-selection",
|
|
145
|
+
"--title=Open SQLite Database",
|
|
146
|
+
`--filename=${homeDirectory}${path.sep}`,
|
|
147
|
+
"--file-filter=SQLite databases | *.db *.sqlite *.sqlite3",
|
|
148
|
+
"--file-filter=All files | *",
|
|
149
|
+
],
|
|
150
|
+
cancelledExitCodes: new Set([1]),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
command: "kdialog",
|
|
154
|
+
args: [
|
|
155
|
+
"--getopenfilename",
|
|
156
|
+
homeDirectory,
|
|
157
|
+
"SQLite databases (*.db *.sqlite *.sqlite3);;All files (*)",
|
|
158
|
+
"--title",
|
|
159
|
+
"Open SQLite Database",
|
|
160
|
+
],
|
|
161
|
+
cancelledExitCodes: new Set([1]),
|
|
162
|
+
},
|
|
163
|
+
];
|
|
164
|
+
}
|
|
165
|
+
|
|
93
166
|
function normalizeSelectedDatabasePath(value) {
|
|
94
167
|
const selectedPath = String(value ?? "").trim();
|
|
95
168
|
|
|
@@ -100,6 +173,10 @@ function normalizeSelectedDatabasePath(value) {
|
|
|
100
173
|
return path.extname(selectedPath) ? selectedPath : `${selectedPath}.sqlite`;
|
|
101
174
|
}
|
|
102
175
|
|
|
176
|
+
function normalizeOpenedDatabasePath(value) {
|
|
177
|
+
return String(value ?? "").trim() || null;
|
|
178
|
+
}
|
|
179
|
+
|
|
103
180
|
function isMissingDialogCommand(error) {
|
|
104
181
|
return error?.code === "ENOENT";
|
|
105
182
|
}
|
|
@@ -129,6 +206,19 @@ class NativeFileDialogService {
|
|
|
129
206
|
homeDirectory: this.homeDirectory,
|
|
130
207
|
});
|
|
131
208
|
|
|
209
|
+
return this.runDialogAttempts(attempts, normalizeSelectedDatabasePath);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async chooseOpenDatabasePath() {
|
|
213
|
+
const attempts = buildOpenDialogAttempts({
|
|
214
|
+
platform: this.platform,
|
|
215
|
+
homeDirectory: this.homeDirectory,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
return this.runDialogAttempts(attempts, normalizeOpenedDatabasePath);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async runDialogAttempts(attempts, normalizePath) {
|
|
132
222
|
for (const attempt of attempts) {
|
|
133
223
|
try {
|
|
134
224
|
const result = await this.executeFile(attempt.command, attempt.args, {
|
|
@@ -136,7 +226,7 @@ class NativeFileDialogService {
|
|
|
136
226
|
windowsHide: true,
|
|
137
227
|
});
|
|
138
228
|
|
|
139
|
-
return
|
|
229
|
+
return normalizePath(result?.stdout);
|
|
140
230
|
} catch (error) {
|
|
141
231
|
if (isCancelledDialog(error, attempt)) {
|
|
142
232
|
return null;
|
|
@@ -164,5 +254,7 @@ module.exports = {
|
|
|
164
254
|
DEFAULT_DATABASE_FILENAME,
|
|
165
255
|
NativeFileDialogService,
|
|
166
256
|
buildDialogAttempts,
|
|
257
|
+
buildOpenDialogAttempts,
|
|
258
|
+
normalizeOpenedDatabasePath,
|
|
167
259
|
normalizeSelectedDatabasePath,
|
|
168
260
|
};
|
|
@@ -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
|
|
|
@@ -457,6 +457,19 @@ class AppStateStore {
|
|
|
457
457
|
|
|
458
458
|
CREATE INDEX IF NOT EXISTS idx_database_documents_database_updated
|
|
459
459
|
ON database_documents(database_key, updated_at DESC, id ASC);
|
|
460
|
+
|
|
461
|
+
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
462
|
+
id TEXT PRIMARY KEY,
|
|
463
|
+
database_key TEXT NOT NULL,
|
|
464
|
+
name TEXT NOT NULL,
|
|
465
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
466
|
+
token_prefix TEXT NOT NULL,
|
|
467
|
+
created_at TEXT NOT NULL,
|
|
468
|
+
last_used_at TEXT
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
CREATE INDEX IF NOT EXISTS idx_api_tokens_database_created
|
|
472
|
+
ON api_tokens(database_key, created_at DESC, id ASC);
|
|
460
473
|
`);
|
|
461
474
|
|
|
462
475
|
const recentConnectionColumns = new Set(
|
|
@@ -2335,6 +2348,106 @@ class AppStateStore {
|
|
|
2335
2348
|
return this.getSettings();
|
|
2336
2349
|
}
|
|
2337
2350
|
|
|
2351
|
+
decorateApiTokenRow(row = {}) {
|
|
2352
|
+
return {
|
|
2353
|
+
id: String(row.id ?? ""),
|
|
2354
|
+
databaseKey: String(row.database_key ?? row.databaseKey ?? ""),
|
|
2355
|
+
name: String(row.name ?? ""),
|
|
2356
|
+
tokenPrefix: String(row.token_prefix ?? row.tokenPrefix ?? ""),
|
|
2357
|
+
createdAt: row.created_at ?? row.createdAt ?? null,
|
|
2358
|
+
lastUsedAt: row.last_used_at ?? row.lastUsedAt ?? null,
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
listApiTokens(databaseKey) {
|
|
2363
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
2364
|
+
|
|
2365
|
+
return this.db
|
|
2366
|
+
.prepare(
|
|
2367
|
+
`
|
|
2368
|
+
SELECT id, database_key, name, token_prefix, created_at, last_used_at
|
|
2369
|
+
FROM api_tokens
|
|
2370
|
+
WHERE database_key = ?
|
|
2371
|
+
ORDER BY created_at DESC, id ASC
|
|
2372
|
+
`
|
|
2373
|
+
)
|
|
2374
|
+
.all(normalizedDatabaseKey)
|
|
2375
|
+
.map((row) => this.decorateApiTokenRow(row));
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
createApiToken({ databaseKey, name, tokenHash, tokenPrefix }) {
|
|
2379
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
2380
|
+
const id = crypto.randomUUID();
|
|
2381
|
+
const createdAt = new Date().toISOString();
|
|
2382
|
+
|
|
2383
|
+
this.db
|
|
2384
|
+
.prepare(
|
|
2385
|
+
`
|
|
2386
|
+
INSERT INTO api_tokens (
|
|
2387
|
+
id,
|
|
2388
|
+
database_key,
|
|
2389
|
+
name,
|
|
2390
|
+
token_hash,
|
|
2391
|
+
token_prefix,
|
|
2392
|
+
created_at
|
|
2393
|
+
)
|
|
2394
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
2395
|
+
`
|
|
2396
|
+
)
|
|
2397
|
+
.run(id, normalizedDatabaseKey, name, tokenHash, tokenPrefix, createdAt);
|
|
2398
|
+
|
|
2399
|
+
return this.listApiTokens(normalizedDatabaseKey).find((token) => token.id === id);
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
findApiTokenByHash(tokenHash) {
|
|
2403
|
+
const normalizedTokenHash = String(tokenHash ?? "").trim();
|
|
2404
|
+
|
|
2405
|
+
if (!normalizedTokenHash) {
|
|
2406
|
+
return null;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
const row = this.db
|
|
2410
|
+
.prepare(
|
|
2411
|
+
`
|
|
2412
|
+
SELECT id, database_key, name, token_prefix, created_at, last_used_at
|
|
2413
|
+
FROM api_tokens
|
|
2414
|
+
WHERE token_hash = ?
|
|
2415
|
+
LIMIT 1
|
|
2416
|
+
`
|
|
2417
|
+
)
|
|
2418
|
+
.get(normalizedTokenHash);
|
|
2419
|
+
|
|
2420
|
+
return row ? this.decorateApiTokenRow(row) : null;
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
touchApiToken(tokenId) {
|
|
2424
|
+
this.db
|
|
2425
|
+
.prepare("UPDATE api_tokens SET last_used_at = ? WHERE id = ?")
|
|
2426
|
+
.run(new Date().toISOString(), String(tokenId ?? "").trim());
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
deleteApiToken(databaseKey, tokenId) {
|
|
2430
|
+
const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
|
|
2431
|
+
const normalizedTokenId = String(tokenId ?? "").trim();
|
|
2432
|
+
|
|
2433
|
+
if (!normalizedTokenId) {
|
|
2434
|
+
throw new ValidationError("Token id is required.");
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
const result = this.db
|
|
2438
|
+
.prepare("DELETE FROM api_tokens WHERE database_key = ? AND id = ?")
|
|
2439
|
+
.run(normalizedDatabaseKey, normalizedTokenId);
|
|
2440
|
+
|
|
2441
|
+
if (result.changes < 1) {
|
|
2442
|
+
throw new NotFoundError("API token was not found.");
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
return {
|
|
2446
|
+
id: normalizedTokenId,
|
|
2447
|
+
deleted: true,
|
|
2448
|
+
};
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2338
2451
|
decorateDatabaseDocumentRow(row = {}) {
|
|
2339
2452
|
return {
|
|
2340
2453
|
id: String(row.id ?? ""),
|
package/server/utils/errors.js
CHANGED
|
@@ -29,6 +29,12 @@ class ConflictError extends AppError {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
class AuthenticationError extends AppError {
|
|
33
|
+
constructor(message = "Authentication required.", options = {}) {
|
|
34
|
+
super(message, 401, { code: "AUTHENTICATION_REQUIRED", ...options });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
class BusyError extends AppError {
|
|
33
39
|
constructor(message, options = {}) {
|
|
34
40
|
super(message, 423, { code: "SQLITE_BUSY", ...options });
|
|
@@ -161,6 +167,7 @@ function errorMiddleware(error, req, res, next) {
|
|
|
161
167
|
|
|
162
168
|
module.exports = {
|
|
163
169
|
AppError,
|
|
170
|
+
AuthenticationError,
|
|
164
171
|
BusyError,
|
|
165
172
|
ConflictError,
|
|
166
173
|
DatabaseRequiredError,
|