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.
Files changed (61) hide show
  1. package/README.md +101 -19
  2. package/bin/sqlite-hub.js +83 -401
  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 +19 -0
  27. package/frontend/js/app.js +64 -0
  28. package/frontend/js/components/formControls.js +38 -0
  29. package/frontend/js/components/modal.js +60 -20
  30. package/frontend/js/store.js +115 -0
  31. package/frontend/js/views/settings.js +141 -5
  32. package/frontend/styles/tailwind.generated.css +2 -2
  33. package/package.json +6 -6
  34. package/server/middleware/apiTokenAuth.js +30 -0
  35. package/server/routes/connections.js +17 -0
  36. package/server/routes/externalApi.js +222 -0
  37. package/server/routes/settings.js +64 -4
  38. package/server/server.js +22 -1
  39. package/server/services/apiTokenService.js +101 -0
  40. package/server/services/databaseCommandService.js +399 -0
  41. package/server/services/nativeFileDialogService.js +93 -1
  42. package/server/services/storage/appStateStore.js +113 -0
  43. package/server/utils/errors.js +7 -0
  44. package/tests/api-token-auth.test.js +127 -0
  45. package/tests/cli-service-delegation.test.js +43 -0
  46. package/tests/connections-file-dialog-route.test.js +43 -0
  47. package/tests/copy-column-modal.test.js +34 -0
  48. package/tests/database-command-service.test.js +102 -0
  49. package/tests/form-controls.test.js +34 -0
  50. package/tests/native-file-dialog.test.js +27 -0
  51. package/tests/settings-api-tokens-route.test.js +76 -0
  52. package/tests/settings-view.test.js +47 -0
  53. package/frontend/assets/mockups/connections.png +0 -0
  54. package/frontend/assets/mockups/data.png +0 -0
  55. package/frontend/assets/mockups/data_row_editor.png +0 -0
  56. package/frontend/assets/mockups/home.png +0 -0
  57. package/frontend/assets/mockups/sql_editor.png +0 -0
  58. package/frontend/assets/mockups/sql_editor_croped.png +0 -0
  59. package/frontend/assets/mockups/sql_editor_querydetail.png +0 -0
  60. package/frontend/assets/mockups/structure.png +0 -0
  61. 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 createSettingsRouter({ appStateStore }) {
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: readSettingsMetadata(),
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: readSettingsMetadata(),
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("/api/settings", createSettingsRouter({ appStateStore }));
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
+ };