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.
Files changed (85) hide show
  1. package/README.md +143 -47
  2. package/bin/sqlite-hub.js +206 -444
  3. package/examples/api/queries.js +31 -0
  4. package/examples/api/rows.js +27 -0
  5. package/frontend/assets/mockups/charts_1_bars_1200.webp +0 -0
  6. package/frontend/assets/mockups/charts_2_pie_1200.webp +0 -0
  7. package/frontend/assets/mockups/charts_3_scatter_plot_1200.webp +0 -0
  8. package/frontend/assets/mockups/connections_1200.webp +0 -0
  9. package/frontend/assets/mockups/data_1_1200.webp +0 -0
  10. package/frontend/assets/mockups/data_2_row_editor_1200.webp +0 -0
  11. package/frontend/assets/mockups/documents_1200.webp +0 -0
  12. package/frontend/assets/mockups/media_tagging_1_setup_1200.webp +0 -0
  13. package/frontend/assets/mockups/media_tagging_2_tagging_queue_1200.webp +0 -0
  14. package/frontend/assets/mockups/media_tagging_3_media_viewer_1200.webp +0 -0
  15. package/frontend/assets/mockups/overview_1200.webp +0 -0
  16. package/frontend/assets/mockups/settings_1200.webp +0 -0
  17. package/frontend/assets/mockups/sql_editor_1_1200.webp +0 -0
  18. package/frontend/assets/mockups/sql_editor_2_query_details_1200.webp +0 -0
  19. package/frontend/assets/mockups/sql_editor_3_export_column_1200.webp +0 -0
  20. package/frontend/assets/mockups/sql_editor_4_export_column_as_markdown_1200.webp +0 -0
  21. package/frontend/assets/mockups/sql_editor_5_export_query_result_1200.webp +0 -0
  22. package/frontend/assets/mockups/structure_1_1200.webp +0 -0
  23. package/frontend/assets/mockups/structure_2_inspector_1200.webp +0 -0
  24. package/frontend/assets/mockups/table_designer_1_1200.webp +0 -0
  25. package/frontend/assets/mockups/table_designer_2_checks_1200.webp +0 -0
  26. package/frontend/js/api.js +25 -0
  27. package/frontend/js/app.js +263 -34
  28. package/frontend/js/components/formControls.js +38 -0
  29. package/frontend/js/components/modal.js +77 -21
  30. package/frontend/js/components/queryChartRenderer.js +28 -3
  31. package/frontend/js/components/queryEditor.js +20 -24
  32. package/frontend/js/components/queryHistoryDetail.js +1 -1
  33. package/frontend/js/components/queryHistoryHeader.js +48 -0
  34. package/frontend/js/components/queryHistoryList.js +132 -0
  35. package/frontend/js/components/queryHistoryPanel.js +72 -136
  36. package/frontend/js/components/structureGraph.js +699 -89
  37. package/frontend/js/components/tableDesignerEditor.js +3 -5
  38. package/frontend/js/store.js +188 -7
  39. package/frontend/js/utils/exportFilenames.js +3 -1
  40. package/frontend/js/views/charts.js +320 -169
  41. package/frontend/js/views/connections.js +59 -64
  42. package/frontend/js/views/data.js +12 -20
  43. package/frontend/js/views/editor.js +0 -2
  44. package/frontend/js/views/settings.js +219 -5
  45. package/frontend/js/views/structure.js +27 -13
  46. package/frontend/styles/components.css +155 -0
  47. package/frontend/styles/structure-graph.css +140 -35
  48. package/frontend/styles/tailwind.generated.css +2 -2
  49. package/frontend/styles/views.css +12 -6
  50. package/package.json +7 -6
  51. package/server/middleware/apiTokenAuth.js +30 -0
  52. package/server/routes/connections.js +17 -0
  53. package/server/routes/export.js +89 -22
  54. package/server/routes/externalApi.js +304 -0
  55. package/server/routes/settings.js +90 -21
  56. package/server/server.js +22 -1
  57. package/server/services/apiTokenService.js +101 -0
  58. package/server/services/appInfoService.js +215 -0
  59. package/server/services/databaseCommandService.js +443 -0
  60. package/server/services/nativeFileDialogService.js +93 -1
  61. package/server/services/sqlite/exportService.js +307 -22
  62. package/server/services/storage/appStateStore.js +113 -0
  63. package/server/utils/errors.js +7 -0
  64. package/tests/api-token-auth.test.js +236 -0
  65. package/tests/cli-args.test.js +16 -3
  66. package/tests/cli-service-delegation.test.js +43 -0
  67. package/tests/connections-file-dialog-route.test.js +43 -0
  68. package/tests/copy-column-modal.test.js +34 -0
  69. package/tests/database-command-service.test.js +139 -0
  70. package/tests/export-blob.test.js +54 -1
  71. package/tests/export-filenames.test.js +4 -0
  72. package/tests/form-controls.test.js +34 -0
  73. package/tests/native-file-dialog.test.js +27 -0
  74. package/tests/settings-api-tokens-route.test.js +97 -0
  75. package/tests/settings-metadata.test.js +99 -1
  76. package/tests/settings-view.test.js +75 -0
  77. package/frontend/assets/mockups/connections.png +0 -0
  78. package/frontend/assets/mockups/data.png +0 -0
  79. package/frontend/assets/mockups/data_row_editor.png +0 -0
  80. package/frontend/assets/mockups/home.png +0 -0
  81. package/frontend/assets/mockups/sql_editor.png +0 -0
  82. package/frontend/assets/mockups/sql_editor_croped.png +0 -0
  83. package/frontend/assets/mockups/sql_editor_querydetail.png +0 -0
  84. package/frontend/assets/mockups/structure.png +0 -0
  85. package/frontend/assets/mockups/structure_inspector.png +0 -0
@@ -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
+ };
@@ -0,0 +1,443 @@
1
+ const path = require("node:path");
2
+ const { NotFoundError, ReadOnlyError, 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 normalizeOptionalValue(value) {
20
+ const normalized = String(value ?? "").trim();
21
+ return normalized || null;
22
+ }
23
+
24
+ function getQueryTitle(item) {
25
+ return item?.title || item?.displayTitle || item?.previewSql || item?.rawSql || "(untitled query)";
26
+ }
27
+
28
+ function getDocumentTitle(document) {
29
+ return document?.filename || document?.title || document?.id || "(untitled document)";
30
+ }
31
+
32
+ function sanitizeFilenameBase(value, fallback = "export") {
33
+ const sanitized = String(value ?? "")
34
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, " ")
35
+ .replace(/\s+/g, " ")
36
+ .trim()
37
+ .replace(/[. ]+$/g, "");
38
+
39
+ return (sanitized || fallback).slice(0, 120);
40
+ }
41
+
42
+ function normalizeMarkdownExportFilename(filename, fallback = "document.md") {
43
+ let normalizedFilename = String(filename ?? "")
44
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
45
+ .replace(/[<>:"/\\|?*]+/g, " ")
46
+ .replace(/\s+/g, " ")
47
+ .trim()
48
+ .replace(/^\.+/, "")
49
+ .replace(/[. ]+$/g, "");
50
+
51
+ if (!normalizedFilename) {
52
+ normalizedFilename = fallback;
53
+ }
54
+
55
+ if (!/\.md$/i.test(normalizedFilename)) {
56
+ normalizedFilename = `${normalizedFilename}.md`;
57
+ }
58
+
59
+ if (normalizedFilename.length > 160) {
60
+ normalizedFilename = `${normalizedFilename.slice(0, 157)}.md`;
61
+ }
62
+
63
+ return normalizedFilename;
64
+ }
65
+
66
+ function coerceIdentityValue(column, value) {
67
+ const text = String(value ?? "");
68
+ const affinity = String(column?.affinity ?? "").toUpperCase();
69
+
70
+ if (["INTEGER", "REAL", "NUMERIC"].includes(affinity) && text.trim() !== "") {
71
+ const numberValue = Number(text);
72
+
73
+ if (Number.isFinite(numberValue)) {
74
+ return numberValue;
75
+ }
76
+ }
77
+
78
+ return value;
79
+ }
80
+
81
+ function parseCompositePrimaryKeyValue(rawValue) {
82
+ if (rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)) {
83
+ return rawValue;
84
+ }
85
+
86
+ try {
87
+ const parsed = JSON.parse(rawValue);
88
+
89
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
90
+ return parsed;
91
+ }
92
+ } catch (error) {
93
+ // Fall through to the validation error below.
94
+ }
95
+
96
+ throw new ValidationError(
97
+ 'Composite primary key export requires a JSON object, for example {"id":1,"locale":"en"}.'
98
+ );
99
+ }
100
+
101
+ function buildIdentityFromExportTarget(tableDetail, exportTarget) {
102
+ if (tableDetail.identityStrategy?.type === "rowid") {
103
+ const numberValue = Number(exportTarget);
104
+
105
+ return {
106
+ kind: "rowid",
107
+ values: {
108
+ rowid: Number.isInteger(numberValue) ? numberValue : exportTarget,
109
+ },
110
+ };
111
+ }
112
+
113
+ if (tableDetail.identityStrategy?.type === "primaryKey") {
114
+ const columns = tableDetail.identityStrategy.columns ?? [];
115
+
116
+ if (columns.length === 1) {
117
+ const columnName = columns[0];
118
+ const column = tableDetail.columns.find((candidate) => candidate.name === columnName);
119
+
120
+ return {
121
+ kind: "primaryKey",
122
+ columns,
123
+ values: {
124
+ [columnName]: coerceIdentityValue(column, exportTarget),
125
+ },
126
+ };
127
+ }
128
+
129
+ const parsed = parseCompositePrimaryKeyValue(exportTarget);
130
+
131
+ return {
132
+ kind: "primaryKey",
133
+ columns,
134
+ values: Object.fromEntries(
135
+ columns.map((columnName) => {
136
+ if (!Object.prototype.hasOwnProperty.call(parsed, columnName)) {
137
+ throw new ValidationError(`Missing primary key value for ${columnName}.`);
138
+ }
139
+
140
+ const column = tableDetail.columns.find((candidate) => candidate.name === columnName);
141
+ return [columnName, coerceIdentityValue(column, parsed[columnName])];
142
+ })
143
+ ),
144
+ };
145
+ }
146
+
147
+ throw new ValidationError(`Table ${tableDetail.name} has no stable row identity.`);
148
+ }
149
+
150
+ function buildRowJsonObject({ row, columns = [] } = {}) {
151
+ const names = columns
152
+ .map((column) => String(typeof column === "object" ? column?.name : column ?? "").trim())
153
+ .filter((name) => name && name !== "__identity");
154
+ const sourceNames = names.length
155
+ ? names
156
+ : Object.keys(row ?? {}).filter((name) => name !== "__identity");
157
+
158
+ return Object.fromEntries(
159
+ sourceNames
160
+ .map((name) => [name, Object.prototype.hasOwnProperty.call(row ?? {}, name) ? row[name] : undefined])
161
+ .filter(([, value]) => value !== undefined)
162
+ );
163
+ }
164
+
165
+ class DatabaseCommandService {
166
+ constructor({ appStateStore, runtimeFactory } = {}) {
167
+ this.appStateStore = appStateStore;
168
+ this.runtimeFactory = runtimeFactory ?? ((connection, options) => this.createRuntime(connection, options));
169
+ }
170
+
171
+ listDatabases() {
172
+ return this.appStateStore.getRecentConnections();
173
+ }
174
+
175
+ getDatabase(databaseReference) {
176
+ const normalizedReference = normalizeLookupValue(databaseReference, "Database").toLowerCase();
177
+ const connection = this.listDatabases().find(
178
+ (candidate) =>
179
+ String(candidate.label ?? "").toLowerCase() === normalizedReference ||
180
+ String(candidate.id ?? "").toLowerCase() === normalizedReference
181
+ );
182
+
183
+ if (!connection) {
184
+ throw new NotFoundError(`Database not found: ${databaseReference}`);
185
+ }
186
+
187
+ return connection;
188
+ }
189
+
190
+ createRuntime(connection, { readOnly = true } = {}) {
191
+ const connectionManager = new ConnectionManager({ appStateStore: this.appStateStore });
192
+
193
+ connectionManager.openConnection({
194
+ filePath: connection.path,
195
+ label: connection.label,
196
+ id: connection.id,
197
+ logoPath: connection.logoPath ?? null,
198
+ makeActive: false,
199
+ readOnly,
200
+ });
201
+
202
+ const sqlExecutor = new SqlExecutor({
203
+ connectionManager,
204
+ appStateStore: this.appStateStore,
205
+ });
206
+
207
+ return {
208
+ connectionManager,
209
+ dataBrowserService: new DataBrowserService({ connectionManager }),
210
+ db: connectionManager.getActiveDatabase(),
211
+ exportService: new ExportService({
212
+ appStateStore: this.appStateStore,
213
+ connectionManager,
214
+ sqlExecutor,
215
+ }),
216
+ sqlExecutor,
217
+ close() {
218
+ connectionManager.closeCurrent();
219
+ },
220
+ };
221
+ }
222
+
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 = {}) {
232
+ const connection = this.getDatabase(databaseReference);
233
+ const runtime = this.runtimeFactory(connection, options);
234
+
235
+ try {
236
+ return callback({ connection, runtime });
237
+ } finally {
238
+ runtime.close?.();
239
+ }
240
+ }
241
+
242
+ listTables(databaseReference) {
243
+ return this.withDatabase(databaseReference, ({ runtime }) => runtime.dataBrowserService.listTables());
244
+ }
245
+
246
+ getTable(databaseReference, tableName) {
247
+ const normalizedTableName = normalizeLookupValue(tableName, "Table name");
248
+ return this.withDatabase(databaseReference, ({ runtime }) =>
249
+ getTableDetail(runtime.db, normalizedTableName)
250
+ );
251
+ }
252
+
253
+ getTableRow(databaseReference, tableName, exportTarget) {
254
+ const normalizedTableName = normalizeLookupValue(tableName, "Table name");
255
+ normalizeLookupValue(exportTarget, "Row key");
256
+
257
+ return this.withDatabase(databaseReference, ({ runtime }) => {
258
+ const tableDetail = getTableDetail(runtime.db, normalizedTableName, {
259
+ includeRowCount: false,
260
+ });
261
+ const identity = buildIdentityFromExportTarget(tableDetail, exportTarget);
262
+ const { row } = runtime.dataBrowserService.getTableRow(normalizedTableName, { identity });
263
+ const data = buildRowJsonObject({
264
+ row,
265
+ columns: tableDetail.columns.filter((column) => column.visible),
266
+ });
267
+
268
+ return {
269
+ data,
270
+ filename: `${sanitizeFilenameBase(
271
+ `${normalizedTableName}-${typeof exportTarget === "object" ? JSON.stringify(exportTarget) : exportTarget}`,
272
+ `${normalizedTableName}-row`
273
+ )}.json`,
274
+ identity,
275
+ table: tableDetail,
276
+ };
277
+ });
278
+ }
279
+
280
+ findQuery(databaseReference, queryName) {
281
+ const connection = this.getDatabase(databaseReference);
282
+ const normalizedQueryName = normalizeLookupValue(queryName, "Query name").toLowerCase();
283
+ const collection = this.appStateStore.buildQueryHistoryCollection({
284
+ databaseKey: connection.id,
285
+ search: queryName,
286
+ onlySaved: false,
287
+ limit: 100,
288
+ });
289
+
290
+ return (
291
+ collection.items.find((item) =>
292
+ [item.id, item.title, item.displayTitle]
293
+ .filter(Boolean)
294
+ .some((candidate) => String(candidate).toLowerCase() === normalizedQueryName)
295
+ ) ??
296
+ collection.items.find((item) =>
297
+ String(item.rawSql ?? "").toLowerCase().includes(normalizedQueryName)
298
+ ) ??
299
+ null
300
+ );
301
+ }
302
+
303
+ requireQuery(databaseReference, queryName) {
304
+ const query = this.findQuery(databaseReference, queryName);
305
+
306
+ if (!query) {
307
+ const available = this.listSavedQueries(databaseReference).items.map(getQueryTitle);
308
+ throw new NotFoundError(`Saved query not found: ${queryName}`, {
309
+ details: { available },
310
+ });
311
+ }
312
+
313
+ return query;
314
+ }
315
+
316
+ listSavedQueries(databaseReference, limit = 100) {
317
+ const connection = this.getDatabase(databaseReference);
318
+ return this.appStateStore.buildQueryHistoryCollection({
319
+ databaseKey: connection.id,
320
+ onlySaved: true,
321
+ limit,
322
+ });
323
+ }
324
+
325
+ getSavedQuery(databaseReference, queryName) {
326
+ return this.requireQuery(databaseReference, queryName);
327
+ }
328
+
329
+ executeSavedQuery(databaseReference, queryName) {
330
+ const query = this.requireQuery(databaseReference, queryName);
331
+ const result = this.withDatabase(databaseReference, ({ runtime }) =>
332
+ runtime.sqlExecutor.execute(query.rawSql, { persistHistory: false })
333
+ );
334
+
335
+ return { query, result };
336
+ }
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
+
369
+ exportSavedQuery(databaseReference, queryName, format = "csv") {
370
+ const query = this.requireQuery(databaseReference, queryName);
371
+ const result = this.withDatabase(databaseReference, ({ runtime }) =>
372
+ runtime.exportService.exportQuery(query.rawSql, { format })
373
+ );
374
+
375
+ return { query, result };
376
+ }
377
+
378
+ listDocuments(databaseReference) {
379
+ const connection = this.getDatabase(databaseReference);
380
+ return this.appStateStore.listDatabaseDocuments(connection.id);
381
+ }
382
+
383
+ findDocument(databaseReference, documentName) {
384
+ const connection = this.getDatabase(databaseReference);
385
+ const normalizedDocumentName = normalizeLookupValue(documentName, "Document name").toLowerCase();
386
+ const documents = this.appStateStore.listDatabaseDocuments(connection.id);
387
+ const exactMatch = documents.find((document) =>
388
+ [document.id, document.filename, document.title]
389
+ .filter(Boolean)
390
+ .some((candidate) => String(candidate).toLowerCase() === normalizedDocumentName)
391
+ );
392
+ const partialMatch = exactMatch ?? documents.find((document) =>
393
+ [document.filename, document.title]
394
+ .filter(Boolean)
395
+ .some((candidate) => String(candidate).toLowerCase().includes(normalizedDocumentName))
396
+ );
397
+
398
+ return partialMatch
399
+ ? this.appStateStore.getDatabaseDocument(connection.id, partialMatch.id)
400
+ : null;
401
+ }
402
+
403
+ requireDocument(databaseReference, documentName) {
404
+ const document = this.findDocument(databaseReference, documentName);
405
+
406
+ if (!document) {
407
+ const available = this.listDocuments(databaseReference).map(getDocumentTitle);
408
+ throw new NotFoundError(`Document not found: ${documentName}`, {
409
+ details: { available },
410
+ });
411
+ }
412
+
413
+ return document;
414
+ }
415
+
416
+ getDocument(databaseReference, documentName) {
417
+ return this.requireDocument(databaseReference, documentName);
418
+ }
419
+
420
+ exportDocument(databaseReference, documentName) {
421
+ const document = this.requireDocument(databaseReference, documentName);
422
+
423
+ return {
424
+ document,
425
+ content: document.content ?? "",
426
+ filename: normalizeMarkdownExportFilename(
427
+ document.filename,
428
+ `${document.title || path.basename(String(documentName)) || "document"}.md`
429
+ ),
430
+ mimeType: "text/markdown; charset=utf-8",
431
+ };
432
+ }
433
+ }
434
+
435
+ module.exports = {
436
+ DatabaseCommandService,
437
+ buildIdentityFromExportTarget,
438
+ buildRowJsonObject,
439
+ getDocumentTitle,
440
+ getQueryTitle,
441
+ normalizeMarkdownExportFilename,
442
+ sanitizeFilenameBase,
443
+ };