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