sqlite-hub 0.17.2 → 1.0.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 +101 -19
- package/bin/sqlite-hub.js +83 -401
- 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 +19 -0
- package/frontend/js/app.js +64 -0
- package/frontend/js/components/formControls.js +38 -0
- package/frontend/js/components/modal.js +60 -20
- package/frontend/js/store.js +115 -0
- package/frontend/js/views/settings.js +141 -5
- package/frontend/styles/tailwind.generated.css +2 -2
- package/package.json +6 -6
- package/server/middleware/apiTokenAuth.js +30 -0
- package/server/routes/connections.js +17 -0
- package/server/routes/externalApi.js +222 -0
- package/server/routes/settings.js +64 -4
- package/server/server.js +22 -1
- package/server/services/apiTokenService.js +101 -0
- package/server/services/databaseCommandService.js +399 -0
- package/server/services/nativeFileDialogService.js +93 -1
- package/server/services/storage/appStateStore.js +113 -0
- package/server/utils/errors.js +7 -0
- package/tests/api-token-auth.test.js +127 -0
- 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 +102 -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 +76 -0
- package/tests/settings-view.test.js +47 -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
|
@@ -2,7 +2,7 @@ const express = require("express");
|
|
|
2
2
|
const Database = require("better-sqlite3");
|
|
3
3
|
const fs = require("node:fs");
|
|
4
4
|
const path = require("node:path");
|
|
5
|
-
const { route, successResponse } = require("../utils/errors");
|
|
5
|
+
const { DatabaseRequiredError, route, successResponse } = require("../utils/errors");
|
|
6
6
|
|
|
7
7
|
function readAppVersion() {
|
|
8
8
|
const packageJsonPath = path.resolve(__dirname, "..", "..", "package.json");
|
|
@@ -27,8 +27,35 @@ function readSettingsMetadata() {
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function
|
|
30
|
+
function getActiveTokenContext({ connectionManager, tokenService }) {
|
|
31
|
+
const activeDatabase = connectionManager?.getActiveConnection?.() ?? null;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
activeDatabase,
|
|
35
|
+
apiTokens: activeDatabase && tokenService ? tokenService.listTokens(activeDatabase.id) : [],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requireActiveDatabase(connectionManager) {
|
|
40
|
+
const activeDatabase = connectionManager?.getActiveConnection?.() ?? null;
|
|
41
|
+
|
|
42
|
+
if (!activeDatabase) {
|
|
43
|
+
throw new DatabaseRequiredError("Select a database before managing API tokens.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return activeDatabase;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildSettingsMetadata(context) {
|
|
50
|
+
return {
|
|
51
|
+
...readSettingsMetadata(),
|
|
52
|
+
...getActiveTokenContext(context),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createSettingsRouter({ appStateStore, connectionManager, tokenService }) {
|
|
31
57
|
const router = express.Router();
|
|
58
|
+
const context = { connectionManager, tokenService };
|
|
32
59
|
|
|
33
60
|
router.get(
|
|
34
61
|
"/",
|
|
@@ -36,7 +63,7 @@ function createSettingsRouter({ appStateStore }) {
|
|
|
36
63
|
res.json(
|
|
37
64
|
successResponse({
|
|
38
65
|
data: appStateStore.getSettings(),
|
|
39
|
-
metadata:
|
|
66
|
+
metadata: buildSettingsMetadata(context),
|
|
40
67
|
})
|
|
41
68
|
);
|
|
42
69
|
})
|
|
@@ -50,7 +77,39 @@ function createSettingsRouter({ appStateStore }) {
|
|
|
50
77
|
successResponse({
|
|
51
78
|
message: "Settings updated.",
|
|
52
79
|
data: settings,
|
|
53
|
-
metadata:
|
|
80
|
+
metadata: buildSettingsMetadata(context),
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
})
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
router.post(
|
|
87
|
+
"/api-tokens",
|
|
88
|
+
route((req, res) => {
|
|
89
|
+
const activeDatabase = requireActiveDatabase(connectionManager);
|
|
90
|
+
const token = tokenService.createToken(activeDatabase.id, req.body?.name);
|
|
91
|
+
|
|
92
|
+
res.status(201).json(
|
|
93
|
+
successResponse({
|
|
94
|
+
message: "API token created. It will only be shown once.",
|
|
95
|
+
data: token,
|
|
96
|
+
metadata: buildSettingsMetadata(context),
|
|
97
|
+
})
|
|
98
|
+
);
|
|
99
|
+
})
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
router.delete(
|
|
103
|
+
"/api-tokens/:tokenId",
|
|
104
|
+
route((req, res) => {
|
|
105
|
+
const activeDatabase = requireActiveDatabase(connectionManager);
|
|
106
|
+
const result = tokenService.deleteToken(activeDatabase.id, req.params.tokenId);
|
|
107
|
+
|
|
108
|
+
res.json(
|
|
109
|
+
successResponse({
|
|
110
|
+
message: "API token deleted.",
|
|
111
|
+
data: result,
|
|
112
|
+
metadata: buildSettingsMetadata(context),
|
|
54
113
|
})
|
|
55
114
|
);
|
|
56
115
|
})
|
|
@@ -61,6 +120,7 @@ function createSettingsRouter({ appStateStore }) {
|
|
|
61
120
|
|
|
62
121
|
module.exports = {
|
|
63
122
|
createSettingsRouter,
|
|
123
|
+
buildSettingsMetadata,
|
|
64
124
|
readSettingsMetadata,
|
|
65
125
|
readSqliteVersion,
|
|
66
126
|
};
|
package/server/server.js
CHANGED
|
@@ -21,6 +21,8 @@ const { StructureService } = require("./services/sqlite/structureService");
|
|
|
21
21
|
const { DataBrowserService } = require("./services/sqlite/dataBrowserService");
|
|
22
22
|
const { TableDesignerService } = require("./services/sqlite/tableDesignerService");
|
|
23
23
|
const { MediaTaggingService } = require("./services/sqlite/mediaTaggingService");
|
|
24
|
+
const { ApiTokenService } = require("./services/apiTokenService");
|
|
25
|
+
const { DatabaseCommandService } = require("./services/databaseCommandService");
|
|
24
26
|
const { createConnectionsRouter } = require("./routes/connections");
|
|
25
27
|
const { createOverviewRouter } = require("./routes/overview");
|
|
26
28
|
const { createSqlRouter } = require("./routes/sql");
|
|
@@ -32,6 +34,7 @@ const { createMediaTaggingRouter } = require("./routes/mediaTagging");
|
|
|
32
34
|
const { createSettingsRouter } = require("./routes/settings");
|
|
33
35
|
const { createExportRouter } = require("./routes/export");
|
|
34
36
|
const { createDocumentsRouter } = require("./routes/documents");
|
|
37
|
+
const { createExternalApiRouter } = require("./routes/externalApi");
|
|
35
38
|
|
|
36
39
|
const PACKAGE_ROOT = path.resolve(__dirname, "..");
|
|
37
40
|
const FRONTEND_ROOT = path.join(PACKAGE_ROOT, "frontend");
|
|
@@ -64,6 +67,8 @@ const structureService = new StructureService({ connectionManager, appStateStore
|
|
|
64
67
|
const dataBrowserService = new DataBrowserService({ connectionManager });
|
|
65
68
|
const tableDesignerService = new TableDesignerService({ connectionManager });
|
|
66
69
|
const mediaTaggingService = new MediaTaggingService({ connectionManager, appStateStore });
|
|
70
|
+
const apiTokenService = new ApiTokenService({ appStateStore });
|
|
71
|
+
const databaseCommandService = new DatabaseCommandService({ appStateStore });
|
|
67
72
|
|
|
68
73
|
connectionManager.initialize();
|
|
69
74
|
|
|
@@ -116,9 +121,23 @@ app.use("/api/structure", createStructureRouter({ structureService }));
|
|
|
116
121
|
app.use("/api/data", createDataRouter({ dataBrowserService }));
|
|
117
122
|
app.use("/api/table-designer", createTableDesignerRouter({ tableDesignerService }));
|
|
118
123
|
app.use("/api/media-tagging", createMediaTaggingRouter({ mediaTaggingService }));
|
|
119
|
-
app.use(
|
|
124
|
+
app.use(
|
|
125
|
+
"/api/settings",
|
|
126
|
+
createSettingsRouter({
|
|
127
|
+
appStateStore,
|
|
128
|
+
connectionManager,
|
|
129
|
+
tokenService: apiTokenService,
|
|
130
|
+
})
|
|
131
|
+
);
|
|
120
132
|
app.use("/api/export", createExportRouter({ exportService }));
|
|
121
133
|
app.use("/api/documents", createDocumentsRouter({ appStateStore, connectionManager }));
|
|
134
|
+
app.use(
|
|
135
|
+
"/api/v1",
|
|
136
|
+
createExternalApiRouter({
|
|
137
|
+
databaseService: databaseCommandService,
|
|
138
|
+
tokenService: apiTokenService,
|
|
139
|
+
})
|
|
140
|
+
);
|
|
122
141
|
|
|
123
142
|
// auth: public favicon response; it exposes no application data.
|
|
124
143
|
app.get("/favicon.ico", (req, res) => {
|
|
@@ -227,7 +246,9 @@ if (require.main === module) {
|
|
|
227
246
|
module.exports = {
|
|
228
247
|
app,
|
|
229
248
|
appStateStore,
|
|
249
|
+
apiTokenService,
|
|
230
250
|
connectionManager,
|
|
251
|
+
databaseCommandService,
|
|
231
252
|
DEFAULT_HOST,
|
|
232
253
|
DEFAULT_PORT,
|
|
233
254
|
parsePortArgument,
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const crypto = require("node:crypto");
|
|
2
|
+
const { AuthenticationError, NotFoundError, ValidationError } = require("../utils/errors");
|
|
3
|
+
|
|
4
|
+
const TOKEN_PREFIX = "shub_";
|
|
5
|
+
|
|
6
|
+
function hashApiToken(token) {
|
|
7
|
+
return crypto.createHash("sha256").update(String(token ?? ""), "utf8").digest("hex");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizeDatabaseKey(databaseKey) {
|
|
11
|
+
const normalized = String(databaseKey ?? "").trim();
|
|
12
|
+
|
|
13
|
+
if (!normalized) {
|
|
14
|
+
throw new ValidationError("Database key is required.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return normalized;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeTokenName(name) {
|
|
21
|
+
const normalized = String(name ?? "").trim() || "API token";
|
|
22
|
+
|
|
23
|
+
if (normalized.length > 80) {
|
|
24
|
+
throw new ValidationError("Token name must not exceed 80 characters.");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return normalized;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class ApiTokenService {
|
|
31
|
+
constructor({ appStateStore }) {
|
|
32
|
+
this.appStateStore = appStateStore;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
assertDatabaseExists(databaseKey) {
|
|
36
|
+
const normalizedDatabaseKey = normalizeDatabaseKey(databaseKey);
|
|
37
|
+
const connection = this.appStateStore
|
|
38
|
+
.getRecentConnections()
|
|
39
|
+
.find((candidate) => candidate.id === normalizedDatabaseKey);
|
|
40
|
+
|
|
41
|
+
if (!connection) {
|
|
42
|
+
throw new NotFoundError(`Database not found: ${normalizedDatabaseKey}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return connection;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
listTokens(databaseKey) {
|
|
49
|
+
const connection = this.assertDatabaseExists(databaseKey);
|
|
50
|
+
return this.appStateStore.listApiTokens(connection.id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
createToken(databaseKey, name) {
|
|
54
|
+
const connection = this.assertDatabaseExists(databaseKey);
|
|
55
|
+
const token = `${TOKEN_PREFIX}${crypto.randomBytes(32).toString("base64url")}`;
|
|
56
|
+
const record = this.appStateStore.createApiToken({
|
|
57
|
+
databaseKey: connection.id,
|
|
58
|
+
name: normalizeTokenName(name),
|
|
59
|
+
tokenHash: hashApiToken(token),
|
|
60
|
+
tokenPrefix: token.slice(0, 13),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
...record,
|
|
65
|
+
token,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
deleteToken(databaseKey, tokenId) {
|
|
70
|
+
const connection = this.assertDatabaseExists(databaseKey);
|
|
71
|
+
return this.appStateStore.deleteApiToken(connection.id, tokenId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
authenticate(databaseKey, token) {
|
|
75
|
+
const normalizedDatabaseKey = normalizeDatabaseKey(databaseKey);
|
|
76
|
+
const normalizedToken = String(token ?? "").trim();
|
|
77
|
+
|
|
78
|
+
if (!normalizedToken) {
|
|
79
|
+
throw new AuthenticationError("API token is required.", {
|
|
80
|
+
code: "API_TOKEN_REQUIRED",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const record = this.appStateStore.findApiTokenByHash(hashApiToken(normalizedToken));
|
|
85
|
+
|
|
86
|
+
if (!record || record.databaseKey !== normalizedDatabaseKey) {
|
|
87
|
+
throw new AuthenticationError("API token is invalid for this database.", {
|
|
88
|
+
code: "API_TOKEN_INVALID",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.appStateStore.touchApiToken(record.id);
|
|
93
|
+
return record;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
ApiTokenService,
|
|
99
|
+
TOKEN_PREFIX,
|
|
100
|
+
hashApiToken,
|
|
101
|
+
};
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
const path = require("node:path");
|
|
2
|
+
const { NotFoundError, ValidationError } = require("../utils/errors");
|
|
3
|
+
const { ConnectionManager } = require("./sqlite/connectionManager");
|
|
4
|
+
const { DataBrowserService } = require("./sqlite/dataBrowserService");
|
|
5
|
+
const { ExportService } = require("./sqlite/exportService");
|
|
6
|
+
const { getTableDetail } = require("./sqlite/introspection");
|
|
7
|
+
const { SqlExecutor } = require("./sqlite/sqlExecutor");
|
|
8
|
+
|
|
9
|
+
function normalizeLookupValue(value, label) {
|
|
10
|
+
const normalized = String(value ?? "").trim();
|
|
11
|
+
|
|
12
|
+
if (!normalized) {
|
|
13
|
+
throw new ValidationError(`${label} is required.`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return normalized;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getQueryTitle(item) {
|
|
20
|
+
return item?.title || item?.displayTitle || item?.previewSql || item?.rawSql || "(untitled query)";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getDocumentTitle(document) {
|
|
24
|
+
return document?.filename || document?.title || document?.id || "(untitled document)";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function sanitizeFilenameBase(value, fallback = "export") {
|
|
28
|
+
const sanitized = String(value ?? "")
|
|
29
|
+
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, " ")
|
|
30
|
+
.replace(/\s+/g, " ")
|
|
31
|
+
.trim()
|
|
32
|
+
.replace(/[. ]+$/g, "");
|
|
33
|
+
|
|
34
|
+
return (sanitized || fallback).slice(0, 120);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeMarkdownExportFilename(filename, fallback = "document.md") {
|
|
38
|
+
let normalizedFilename = String(filename ?? "")
|
|
39
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
40
|
+
.replace(/[<>:"/\\|?*]+/g, " ")
|
|
41
|
+
.replace(/\s+/g, " ")
|
|
42
|
+
.trim()
|
|
43
|
+
.replace(/^\.+/, "")
|
|
44
|
+
.replace(/[. ]+$/g, "");
|
|
45
|
+
|
|
46
|
+
if (!normalizedFilename) {
|
|
47
|
+
normalizedFilename = fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!/\.md$/i.test(normalizedFilename)) {
|
|
51
|
+
normalizedFilename = `${normalizedFilename}.md`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (normalizedFilename.length > 160) {
|
|
55
|
+
normalizedFilename = `${normalizedFilename.slice(0, 157)}.md`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return normalizedFilename;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function coerceIdentityValue(column, value) {
|
|
62
|
+
const text = String(value ?? "");
|
|
63
|
+
const affinity = String(column?.affinity ?? "").toUpperCase();
|
|
64
|
+
|
|
65
|
+
if (["INTEGER", "REAL", "NUMERIC"].includes(affinity) && text.trim() !== "") {
|
|
66
|
+
const numberValue = Number(text);
|
|
67
|
+
|
|
68
|
+
if (Number.isFinite(numberValue)) {
|
|
69
|
+
return numberValue;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseCompositePrimaryKeyValue(rawValue) {
|
|
77
|
+
if (rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)) {
|
|
78
|
+
return rawValue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(rawValue);
|
|
83
|
+
|
|
84
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
85
|
+
return parsed;
|
|
86
|
+
}
|
|
87
|
+
} catch (error) {
|
|
88
|
+
// Fall through to the validation error below.
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
throw new ValidationError(
|
|
92
|
+
'Composite primary key export requires a JSON object, for example {"id":1,"locale":"en"}.'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildIdentityFromExportTarget(tableDetail, exportTarget) {
|
|
97
|
+
if (tableDetail.identityStrategy?.type === "rowid") {
|
|
98
|
+
const numberValue = Number(exportTarget);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
kind: "rowid",
|
|
102
|
+
values: {
|
|
103
|
+
rowid: Number.isInteger(numberValue) ? numberValue : exportTarget,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (tableDetail.identityStrategy?.type === "primaryKey") {
|
|
109
|
+
const columns = tableDetail.identityStrategy.columns ?? [];
|
|
110
|
+
|
|
111
|
+
if (columns.length === 1) {
|
|
112
|
+
const columnName = columns[0];
|
|
113
|
+
const column = tableDetail.columns.find((candidate) => candidate.name === columnName);
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
kind: "primaryKey",
|
|
117
|
+
columns,
|
|
118
|
+
values: {
|
|
119
|
+
[columnName]: coerceIdentityValue(column, exportTarget),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const parsed = parseCompositePrimaryKeyValue(exportTarget);
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
kind: "primaryKey",
|
|
128
|
+
columns,
|
|
129
|
+
values: Object.fromEntries(
|
|
130
|
+
columns.map((columnName) => {
|
|
131
|
+
if (!Object.prototype.hasOwnProperty.call(parsed, columnName)) {
|
|
132
|
+
throw new ValidationError(`Missing primary key value for ${columnName}.`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const column = tableDetail.columns.find((candidate) => candidate.name === columnName);
|
|
136
|
+
return [columnName, coerceIdentityValue(column, parsed[columnName])];
|
|
137
|
+
})
|
|
138
|
+
),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
throw new ValidationError(`Table ${tableDetail.name} has no stable row identity.`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function buildRowJsonObject({ row, columns = [] } = {}) {
|
|
146
|
+
const names = columns
|
|
147
|
+
.map((column) => String(typeof column === "object" ? column?.name : column ?? "").trim())
|
|
148
|
+
.filter((name) => name && name !== "__identity");
|
|
149
|
+
const sourceNames = names.length
|
|
150
|
+
? names
|
|
151
|
+
: Object.keys(row ?? {}).filter((name) => name !== "__identity");
|
|
152
|
+
|
|
153
|
+
return Object.fromEntries(
|
|
154
|
+
sourceNames
|
|
155
|
+
.map((name) => [name, Object.prototype.hasOwnProperty.call(row ?? {}, name) ? row[name] : undefined])
|
|
156
|
+
.filter(([, value]) => value !== undefined)
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
class DatabaseCommandService {
|
|
161
|
+
constructor({ appStateStore, runtimeFactory } = {}) {
|
|
162
|
+
this.appStateStore = appStateStore;
|
|
163
|
+
this.runtimeFactory = runtimeFactory ?? ((connection) => this.createReadOnlyRuntime(connection));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
listDatabases() {
|
|
167
|
+
return this.appStateStore.getRecentConnections();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
getDatabase(databaseReference) {
|
|
171
|
+
const normalizedReference = normalizeLookupValue(databaseReference, "Database").toLowerCase();
|
|
172
|
+
const connection = this.listDatabases().find(
|
|
173
|
+
(candidate) =>
|
|
174
|
+
String(candidate.label ?? "").toLowerCase() === normalizedReference ||
|
|
175
|
+
String(candidate.id ?? "").toLowerCase() === normalizedReference
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (!connection) {
|
|
179
|
+
throw new NotFoundError(`Database not found: ${databaseReference}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return connection;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
createReadOnlyRuntime(connection) {
|
|
186
|
+
const connectionManager = new ConnectionManager({ appStateStore: this.appStateStore });
|
|
187
|
+
|
|
188
|
+
connectionManager.openConnection({
|
|
189
|
+
filePath: connection.path,
|
|
190
|
+
label: connection.label,
|
|
191
|
+
id: connection.id,
|
|
192
|
+
logoPath: connection.logoPath ?? null,
|
|
193
|
+
makeActive: false,
|
|
194
|
+
readOnly: true,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const sqlExecutor = new SqlExecutor({
|
|
198
|
+
connectionManager,
|
|
199
|
+
appStateStore: this.appStateStore,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
connectionManager,
|
|
204
|
+
dataBrowserService: new DataBrowserService({ connectionManager }),
|
|
205
|
+
db: connectionManager.getActiveDatabase(),
|
|
206
|
+
exportService: new ExportService({
|
|
207
|
+
appStateStore: this.appStateStore,
|
|
208
|
+
connectionManager,
|
|
209
|
+
sqlExecutor,
|
|
210
|
+
}),
|
|
211
|
+
sqlExecutor,
|
|
212
|
+
close() {
|
|
213
|
+
connectionManager.closeCurrent();
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
withDatabase(databaseReference, callback) {
|
|
219
|
+
const connection = this.getDatabase(databaseReference);
|
|
220
|
+
const runtime = this.runtimeFactory(connection);
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
return callback({ connection, runtime });
|
|
224
|
+
} finally {
|
|
225
|
+
runtime.close?.();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
listTables(databaseReference) {
|
|
230
|
+
return this.withDatabase(databaseReference, ({ runtime }) => runtime.dataBrowserService.listTables());
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
getTable(databaseReference, tableName) {
|
|
234
|
+
const normalizedTableName = normalizeLookupValue(tableName, "Table name");
|
|
235
|
+
return this.withDatabase(databaseReference, ({ runtime }) =>
|
|
236
|
+
getTableDetail(runtime.db, normalizedTableName)
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
getTableRow(databaseReference, tableName, exportTarget) {
|
|
241
|
+
const normalizedTableName = normalizeLookupValue(tableName, "Table name");
|
|
242
|
+
normalizeLookupValue(exportTarget, "Row key");
|
|
243
|
+
|
|
244
|
+
return this.withDatabase(databaseReference, ({ runtime }) => {
|
|
245
|
+
const tableDetail = getTableDetail(runtime.db, normalizedTableName, {
|
|
246
|
+
includeRowCount: false,
|
|
247
|
+
});
|
|
248
|
+
const identity = buildIdentityFromExportTarget(tableDetail, exportTarget);
|
|
249
|
+
const { row } = runtime.dataBrowserService.getTableRow(normalizedTableName, { identity });
|
|
250
|
+
const data = buildRowJsonObject({
|
|
251
|
+
row,
|
|
252
|
+
columns: tableDetail.columns.filter((column) => column.visible),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
data,
|
|
257
|
+
filename: `${sanitizeFilenameBase(
|
|
258
|
+
`${normalizedTableName}-${typeof exportTarget === "object" ? JSON.stringify(exportTarget) : exportTarget}`,
|
|
259
|
+
`${normalizedTableName}-row`
|
|
260
|
+
)}.json`,
|
|
261
|
+
identity,
|
|
262
|
+
table: tableDetail,
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
findQuery(databaseReference, queryName) {
|
|
268
|
+
const connection = this.getDatabase(databaseReference);
|
|
269
|
+
const normalizedQueryName = normalizeLookupValue(queryName, "Query name").toLowerCase();
|
|
270
|
+
const collection = this.appStateStore.buildQueryHistoryCollection({
|
|
271
|
+
databaseKey: connection.id,
|
|
272
|
+
search: queryName,
|
|
273
|
+
onlySaved: false,
|
|
274
|
+
limit: 100,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
collection.items.find((item) =>
|
|
279
|
+
[item.id, item.title, item.displayTitle]
|
|
280
|
+
.filter(Boolean)
|
|
281
|
+
.some((candidate) => String(candidate).toLowerCase() === normalizedQueryName)
|
|
282
|
+
) ??
|
|
283
|
+
collection.items.find((item) =>
|
|
284
|
+
String(item.rawSql ?? "").toLowerCase().includes(normalizedQueryName)
|
|
285
|
+
) ??
|
|
286
|
+
null
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
requireQuery(databaseReference, queryName) {
|
|
291
|
+
const query = this.findQuery(databaseReference, queryName);
|
|
292
|
+
|
|
293
|
+
if (!query) {
|
|
294
|
+
const available = this.listSavedQueries(databaseReference).items.map(getQueryTitle);
|
|
295
|
+
throw new NotFoundError(`Saved query not found: ${queryName}`, {
|
|
296
|
+
details: { available },
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return query;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
listSavedQueries(databaseReference, limit = 100) {
|
|
304
|
+
const connection = this.getDatabase(databaseReference);
|
|
305
|
+
return this.appStateStore.buildQueryHistoryCollection({
|
|
306
|
+
databaseKey: connection.id,
|
|
307
|
+
onlySaved: true,
|
|
308
|
+
limit,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
getSavedQuery(databaseReference, queryName) {
|
|
313
|
+
return this.requireQuery(databaseReference, queryName);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
executeSavedQuery(databaseReference, queryName) {
|
|
317
|
+
const query = this.requireQuery(databaseReference, queryName);
|
|
318
|
+
const result = this.withDatabase(databaseReference, ({ runtime }) =>
|
|
319
|
+
runtime.sqlExecutor.execute(query.rawSql, { persistHistory: false })
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
return { query, result };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
exportSavedQuery(databaseReference, queryName, format = "csv") {
|
|
326
|
+
const query = this.requireQuery(databaseReference, queryName);
|
|
327
|
+
const result = this.withDatabase(databaseReference, ({ runtime }) =>
|
|
328
|
+
runtime.exportService.exportQuery(query.rawSql, { format })
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
return { query, result };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
listDocuments(databaseReference) {
|
|
335
|
+
const connection = this.getDatabase(databaseReference);
|
|
336
|
+
return this.appStateStore.listDatabaseDocuments(connection.id);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
findDocument(databaseReference, documentName) {
|
|
340
|
+
const connection = this.getDatabase(databaseReference);
|
|
341
|
+
const normalizedDocumentName = normalizeLookupValue(documentName, "Document name").toLowerCase();
|
|
342
|
+
const documents = this.appStateStore.listDatabaseDocuments(connection.id);
|
|
343
|
+
const exactMatch = documents.find((document) =>
|
|
344
|
+
[document.id, document.filename, document.title]
|
|
345
|
+
.filter(Boolean)
|
|
346
|
+
.some((candidate) => String(candidate).toLowerCase() === normalizedDocumentName)
|
|
347
|
+
);
|
|
348
|
+
const partialMatch = exactMatch ?? documents.find((document) =>
|
|
349
|
+
[document.filename, document.title]
|
|
350
|
+
.filter(Boolean)
|
|
351
|
+
.some((candidate) => String(candidate).toLowerCase().includes(normalizedDocumentName))
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
return partialMatch
|
|
355
|
+
? this.appStateStore.getDatabaseDocument(connection.id, partialMatch.id)
|
|
356
|
+
: null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
requireDocument(databaseReference, documentName) {
|
|
360
|
+
const document = this.findDocument(databaseReference, documentName);
|
|
361
|
+
|
|
362
|
+
if (!document) {
|
|
363
|
+
const available = this.listDocuments(databaseReference).map(getDocumentTitle);
|
|
364
|
+
throw new NotFoundError(`Document not found: ${documentName}`, {
|
|
365
|
+
details: { available },
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return document;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
getDocument(databaseReference, documentName) {
|
|
373
|
+
return this.requireDocument(databaseReference, documentName);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
exportDocument(databaseReference, documentName) {
|
|
377
|
+
const document = this.requireDocument(databaseReference, documentName);
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
document,
|
|
381
|
+
content: document.content ?? "",
|
|
382
|
+
filename: normalizeMarkdownExportFilename(
|
|
383
|
+
document.filename,
|
|
384
|
+
`${document.title || path.basename(String(documentName)) || "document"}.md`
|
|
385
|
+
),
|
|
386
|
+
mimeType: "text/markdown; charset=utf-8",
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
module.exports = {
|
|
392
|
+
DatabaseCommandService,
|
|
393
|
+
buildIdentityFromExportTarget,
|
|
394
|
+
buildRowJsonObject,
|
|
395
|
+
getDocumentTitle,
|
|
396
|
+
getQueryTitle,
|
|
397
|
+
normalizeMarkdownExportFilename,
|
|
398
|
+
sanitizeFilenameBase,
|
|
399
|
+
};
|