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
@@ -90,6 +90,79 @@ function buildDialogAttempts({ platform = process.platform, homeDirectory = os.h
90
90
  ];
91
91
  }
92
92
 
93
+ function buildOpenDialogAttempts({ platform = process.platform, homeDirectory = os.homedir() } = {}) {
94
+ if (platform === "darwin") {
95
+ return [
96
+ {
97
+ command: "osascript",
98
+ args: [
99
+ "-e",
100
+ "on run argv",
101
+ "-e",
102
+ 'set selectedFile to choose file with prompt "Open SQLite Database" default location POSIX file (item 1 of argv)',
103
+ "-e",
104
+ "return POSIX path of selectedFile",
105
+ "-e",
106
+ "end run",
107
+ homeDirectory,
108
+ ],
109
+ cancelledExitCodes: new Set([1]),
110
+ cancelledErrorPattern: /user canceled|-128/i,
111
+ },
112
+ ];
113
+ }
114
+
115
+ if (platform === "win32") {
116
+ const initialDirectory = escapePowerShellSingleQuotedString(homeDirectory);
117
+ const script = [
118
+ "Add-Type -AssemblyName System.Windows.Forms",
119
+ "$dialog = New-Object System.Windows.Forms.OpenFileDialog",
120
+ "$dialog.Title = 'Open SQLite Database'",
121
+ "$dialog.Filter = 'SQLite databases (*.db;*.sqlite;*.sqlite3)|*.db;*.sqlite;*.sqlite3|All files (*.*)|*.*'",
122
+ "$dialog.CheckFileExists = $true",
123
+ `$dialog.InitialDirectory = '${initialDirectory}'`,
124
+ "if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {",
125
+ " [Console]::Out.Write($dialog.FileName)",
126
+ "} else {",
127
+ " exit 2",
128
+ "}",
129
+ ].join("; ");
130
+
131
+ return [
132
+ {
133
+ command: "powershell.exe",
134
+ args: ["-NoProfile", "-STA", "-Command", script],
135
+ cancelledExitCodes: new Set([2]),
136
+ },
137
+ ];
138
+ }
139
+
140
+ return [
141
+ {
142
+ command: "zenity",
143
+ args: [
144
+ "--file-selection",
145
+ "--title=Open SQLite Database",
146
+ `--filename=${homeDirectory}${path.sep}`,
147
+ "--file-filter=SQLite databases | *.db *.sqlite *.sqlite3",
148
+ "--file-filter=All files | *",
149
+ ],
150
+ cancelledExitCodes: new Set([1]),
151
+ },
152
+ {
153
+ command: "kdialog",
154
+ args: [
155
+ "--getopenfilename",
156
+ homeDirectory,
157
+ "SQLite databases (*.db *.sqlite *.sqlite3);;All files (*)",
158
+ "--title",
159
+ "Open SQLite Database",
160
+ ],
161
+ cancelledExitCodes: new Set([1]),
162
+ },
163
+ ];
164
+ }
165
+
93
166
  function normalizeSelectedDatabasePath(value) {
94
167
  const selectedPath = String(value ?? "").trim();
95
168
 
@@ -100,6 +173,10 @@ function normalizeSelectedDatabasePath(value) {
100
173
  return path.extname(selectedPath) ? selectedPath : `${selectedPath}.sqlite`;
101
174
  }
102
175
 
176
+ function normalizeOpenedDatabasePath(value) {
177
+ return String(value ?? "").trim() || null;
178
+ }
179
+
103
180
  function isMissingDialogCommand(error) {
104
181
  return error?.code === "ENOENT";
105
182
  }
@@ -129,6 +206,19 @@ class NativeFileDialogService {
129
206
  homeDirectory: this.homeDirectory,
130
207
  });
131
208
 
209
+ return this.runDialogAttempts(attempts, normalizeSelectedDatabasePath);
210
+ }
211
+
212
+ async chooseOpenDatabasePath() {
213
+ const attempts = buildOpenDialogAttempts({
214
+ platform: this.platform,
215
+ homeDirectory: this.homeDirectory,
216
+ });
217
+
218
+ return this.runDialogAttempts(attempts, normalizeOpenedDatabasePath);
219
+ }
220
+
221
+ async runDialogAttempts(attempts, normalizePath) {
132
222
  for (const attempt of attempts) {
133
223
  try {
134
224
  const result = await this.executeFile(attempt.command, attempt.args, {
@@ -136,7 +226,7 @@ class NativeFileDialogService {
136
226
  windowsHide: true,
137
227
  });
138
228
 
139
- return normalizeSelectedDatabasePath(result?.stdout);
229
+ return normalizePath(result?.stdout);
140
230
  } catch (error) {
141
231
  if (isCancelledDialog(error, attempt)) {
142
232
  return null;
@@ -164,5 +254,7 @@ module.exports = {
164
254
  DEFAULT_DATABASE_FILENAME,
165
255
  NativeFileDialogService,
166
256
  buildDialogAttempts,
257
+ buildOpenDialogAttempts,
258
+ normalizeOpenedDatabasePath,
167
259
  normalizeSelectedDatabasePath,
168
260
  };
@@ -457,6 +457,19 @@ class AppStateStore {
457
457
 
458
458
  CREATE INDEX IF NOT EXISTS idx_database_documents_database_updated
459
459
  ON database_documents(database_key, updated_at DESC, id ASC);
460
+
461
+ CREATE TABLE IF NOT EXISTS api_tokens (
462
+ id TEXT PRIMARY KEY,
463
+ database_key TEXT NOT NULL,
464
+ name TEXT NOT NULL,
465
+ token_hash TEXT NOT NULL UNIQUE,
466
+ token_prefix TEXT NOT NULL,
467
+ created_at TEXT NOT NULL,
468
+ last_used_at TEXT
469
+ );
470
+
471
+ CREATE INDEX IF NOT EXISTS idx_api_tokens_database_created
472
+ ON api_tokens(database_key, created_at DESC, id ASC);
460
473
  `);
461
474
 
462
475
  const recentConnectionColumns = new Set(
@@ -2335,6 +2348,106 @@ class AppStateStore {
2335
2348
  return this.getSettings();
2336
2349
  }
2337
2350
 
2351
+ decorateApiTokenRow(row = {}) {
2352
+ return {
2353
+ id: String(row.id ?? ""),
2354
+ databaseKey: String(row.database_key ?? row.databaseKey ?? ""),
2355
+ name: String(row.name ?? ""),
2356
+ tokenPrefix: String(row.token_prefix ?? row.tokenPrefix ?? ""),
2357
+ createdAt: row.created_at ?? row.createdAt ?? null,
2358
+ lastUsedAt: row.last_used_at ?? row.lastUsedAt ?? null,
2359
+ };
2360
+ }
2361
+
2362
+ listApiTokens(databaseKey) {
2363
+ const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
2364
+
2365
+ return this.db
2366
+ .prepare(
2367
+ `
2368
+ SELECT id, database_key, name, token_prefix, created_at, last_used_at
2369
+ FROM api_tokens
2370
+ WHERE database_key = ?
2371
+ ORDER BY created_at DESC, id ASC
2372
+ `
2373
+ )
2374
+ .all(normalizedDatabaseKey)
2375
+ .map((row) => this.decorateApiTokenRow(row));
2376
+ }
2377
+
2378
+ createApiToken({ databaseKey, name, tokenHash, tokenPrefix }) {
2379
+ const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
2380
+ const id = crypto.randomUUID();
2381
+ const createdAt = new Date().toISOString();
2382
+
2383
+ this.db
2384
+ .prepare(
2385
+ `
2386
+ INSERT INTO api_tokens (
2387
+ id,
2388
+ database_key,
2389
+ name,
2390
+ token_hash,
2391
+ token_prefix,
2392
+ created_at
2393
+ )
2394
+ VALUES (?, ?, ?, ?, ?, ?)
2395
+ `
2396
+ )
2397
+ .run(id, normalizedDatabaseKey, name, tokenHash, tokenPrefix, createdAt);
2398
+
2399
+ return this.listApiTokens(normalizedDatabaseKey).find((token) => token.id === id);
2400
+ }
2401
+
2402
+ findApiTokenByHash(tokenHash) {
2403
+ const normalizedTokenHash = String(tokenHash ?? "").trim();
2404
+
2405
+ if (!normalizedTokenHash) {
2406
+ return null;
2407
+ }
2408
+
2409
+ const row = this.db
2410
+ .prepare(
2411
+ `
2412
+ SELECT id, database_key, name, token_prefix, created_at, last_used_at
2413
+ FROM api_tokens
2414
+ WHERE token_hash = ?
2415
+ LIMIT 1
2416
+ `
2417
+ )
2418
+ .get(normalizedTokenHash);
2419
+
2420
+ return row ? this.decorateApiTokenRow(row) : null;
2421
+ }
2422
+
2423
+ touchApiToken(tokenId) {
2424
+ this.db
2425
+ .prepare("UPDATE api_tokens SET last_used_at = ? WHERE id = ?")
2426
+ .run(new Date().toISOString(), String(tokenId ?? "").trim());
2427
+ }
2428
+
2429
+ deleteApiToken(databaseKey, tokenId) {
2430
+ const normalizedDatabaseKey = normalizeDocumentDatabaseKey(databaseKey);
2431
+ const normalizedTokenId = String(tokenId ?? "").trim();
2432
+
2433
+ if (!normalizedTokenId) {
2434
+ throw new ValidationError("Token id is required.");
2435
+ }
2436
+
2437
+ const result = this.db
2438
+ .prepare("DELETE FROM api_tokens WHERE database_key = ? AND id = ?")
2439
+ .run(normalizedDatabaseKey, normalizedTokenId);
2440
+
2441
+ if (result.changes < 1) {
2442
+ throw new NotFoundError("API token was not found.");
2443
+ }
2444
+
2445
+ return {
2446
+ id: normalizedTokenId,
2447
+ deleted: true,
2448
+ };
2449
+ }
2450
+
2338
2451
  decorateDatabaseDocumentRow(row = {}) {
2339
2452
  return {
2340
2453
  id: String(row.id ?? ""),
@@ -29,6 +29,12 @@ class ConflictError extends AppError {
29
29
  }
30
30
  }
31
31
 
32
+ class AuthenticationError extends AppError {
33
+ constructor(message = "Authentication required.", options = {}) {
34
+ super(message, 401, { code: "AUTHENTICATION_REQUIRED", ...options });
35
+ }
36
+ }
37
+
32
38
  class BusyError extends AppError {
33
39
  constructor(message, options = {}) {
34
40
  super(message, 423, { code: "SQLITE_BUSY", ...options });
@@ -161,6 +167,7 @@ function errorMiddleware(error, req, res, next) {
161
167
 
162
168
  module.exports = {
163
169
  AppError,
170
+ AuthenticationError,
164
171
  BusyError,
165
172
  ConflictError,
166
173
  DatabaseRequiredError,
@@ -0,0 +1,127 @@
1
+ const assert = require("node:assert/strict");
2
+ const express = require("express");
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+ const test = require("node:test");
7
+
8
+ const { createExternalApiRouter } = require("../server/routes/externalApi");
9
+ const { ApiTokenService } = require("../server/services/apiTokenService");
10
+ const { AppStateStore } = require("../server/services/storage/appStateStore");
11
+ const { errorMiddleware } = require("../server/utils/errors");
12
+
13
+ function createConnection(id, label, databasePath) {
14
+ return {
15
+ id,
16
+ label,
17
+ path: databasePath,
18
+ lastOpenedAt: new Date().toISOString(),
19
+ lastModifiedAt: new Date().toISOString(),
20
+ sizeBytes: 0,
21
+ readOnly: false,
22
+ logoPath: null,
23
+ };
24
+ }
25
+
26
+ async function startApi(t) {
27
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-hub-api-token-"));
28
+ const store = new AppStateStore(path.join(directory, "state.db"));
29
+ const databaseA = createConnection("db-a", "Database A", path.join(directory, "a.db"));
30
+ const databaseB = createConnection("db-b", "Database B", path.join(directory, "b.db"));
31
+
32
+ store.upsertRecentConnection(databaseA);
33
+ store.upsertRecentConnection(databaseB, { makeActive: false });
34
+
35
+ const tokenService = new ApiTokenService({ appStateStore: store });
36
+ const serviceCalls = [];
37
+ const databaseService = {
38
+ listTables(databaseId) {
39
+ serviceCalls.push(databaseId);
40
+ return [{ name: "companies" }];
41
+ },
42
+ };
43
+ const app = express();
44
+
45
+ app.use(express.json());
46
+ app.use(
47
+ "/api/v1",
48
+ createExternalApiRouter({ databaseService, tokenService })
49
+ );
50
+ app.use(errorMiddleware);
51
+
52
+ const server = await new Promise((resolve) => {
53
+ const listener = app.listen(0, "127.0.0.1", () => resolve(listener));
54
+ });
55
+
56
+ t.after(async () => {
57
+ await new Promise((resolve, reject) => {
58
+ server.close((error) => (error ? reject(error) : resolve()));
59
+ });
60
+ store.db.close();
61
+ fs.rmSync(directory, { recursive: true, force: true });
62
+ });
63
+
64
+ return {
65
+ baseUrl: `http://127.0.0.1:${server.address().port}/api/v1`,
66
+ databaseA,
67
+ databaseB,
68
+ serviceCalls,
69
+ store,
70
+ tokenService,
71
+ };
72
+ }
73
+
74
+ test("tokens are hashed, deletable, and isolated per database", async (t) => {
75
+ const fixture = await startApi(t);
76
+ const created = fixture.tokenService.createToken(fixture.databaseA.id, "Automation");
77
+
78
+ assert.match(created.token, /^shub_[A-Za-z0-9_-]+$/);
79
+ assert.equal(fixture.tokenService.listTokens(fixture.databaseA.id)[0].token, undefined);
80
+
81
+ const stored = fixture.store.db
82
+ .prepare("SELECT token_hash, token_prefix FROM api_tokens WHERE id = ?")
83
+ .get(created.id);
84
+
85
+ assert.notEqual(stored.token_hash, created.token);
86
+ assert.equal(stored.token_prefix, created.tokenPrefix);
87
+ assert.equal(fixture.tokenService.authenticate(fixture.databaseA.id, created.token).id, created.id);
88
+ assert.throws(
89
+ () => fixture.tokenService.authenticate(fixture.databaseB.id, created.token),
90
+ /invalid for this database/
91
+ );
92
+
93
+ const validResponse = await fetch(
94
+ `${fixture.baseUrl}/databases/${fixture.databaseA.id}/tables`,
95
+ { headers: { Authorization: `Bearer ${created.token}` } }
96
+ );
97
+ const validPayload = await validResponse.json();
98
+
99
+ assert.equal(validResponse.status, 200);
100
+ assert.deepEqual(validPayload.data.items, [{ name: "companies" }]);
101
+ assert.deepEqual(fixture.serviceCalls, [fixture.databaseA.id]);
102
+
103
+ const invalidResponse = await fetch(
104
+ `${fixture.baseUrl}/databases/${fixture.databaseA.id}/tables`,
105
+ { headers: { Authorization: "Bearer invalid" } }
106
+ );
107
+ assert.equal(invalidResponse.status, 401);
108
+
109
+ const missingResponse = await fetch(
110
+ `${fixture.baseUrl}/databases/${fixture.databaseA.id}/tables`
111
+ );
112
+ assert.equal(missingResponse.status, 401);
113
+
114
+ const wrongDatabaseResponse = await fetch(
115
+ `${fixture.baseUrl}/databases/${fixture.databaseB.id}/tables`,
116
+ { headers: { Authorization: `Bearer ${created.token}` } }
117
+ );
118
+ assert.equal(wrongDatabaseResponse.status, 401);
119
+ assert.deepEqual(fixture.serviceCalls, [fixture.databaseA.id]);
120
+
121
+ fixture.tokenService.deleteToken(fixture.databaseA.id, created.id);
122
+ assert.equal(fixture.tokenService.listTokens(fixture.databaseA.id).length, 0);
123
+ assert.throws(
124
+ () => fixture.tokenService.authenticate(fixture.databaseA.id, created.token),
125
+ /invalid for this database/
126
+ );
127
+ });
@@ -0,0 +1,43 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const { main } = require("../bin/sqlite-hub");
5
+
6
+ test("CLI delegates database operations to the shared command service", async () => {
7
+ const calls = [];
8
+ const connection = {
9
+ id: "db-one",
10
+ label: "Database One",
11
+ };
12
+ const databaseService = {
13
+ getDatabase(reference) {
14
+ calls.push(["getDatabase", reference]);
15
+ return connection;
16
+ },
17
+ listDatabases() {
18
+ calls.push(["listDatabases"]);
19
+ return [connection];
20
+ },
21
+ listTables(reference) {
22
+ calls.push(["listTables", reference]);
23
+ return [{ name: "companies" }];
24
+ },
25
+ };
26
+ const output = [];
27
+ const originalLog = console.log;
28
+
29
+ console.log = (...values) => output.push(values.join(" "));
30
+
31
+ try {
32
+ await main(["--database:Database One", "--tables"], { databaseService });
33
+ } finally {
34
+ console.log = originalLog;
35
+ }
36
+
37
+ assert.deepEqual(calls, [
38
+ ["listDatabases"],
39
+ ["getDatabase", "Database One"],
40
+ ["listTables", "db-one"],
41
+ ]);
42
+ assert.match(output.join("\n"), /companies/);
43
+ });
@@ -15,6 +15,7 @@ test("connections route returns the path selected by the native dialog", async (
15
15
  backupService: {},
16
16
  nativeFileDialogService: {
17
17
  chooseCreateDatabasePath: async () => "/tmp/new-database.sqlite",
18
+ chooseOpenDatabasePath: async () => "/tmp/existing-database.db",
18
19
  },
19
20
  })
20
21
  );
@@ -44,3 +45,45 @@ test("connections route returns the path selected by the native dialog", async (
44
45
  });
45
46
  }
46
47
  });
48
+
49
+ test("connections route returns the existing database selected by the native dialog", async () => {
50
+ const app = express();
51
+ app.use(express.json());
52
+ app.use(
53
+ "/api/connections",
54
+ createConnectionsRouter({
55
+ connectionManager: {},
56
+ importService: {},
57
+ backupService: {},
58
+ nativeFileDialogService: {
59
+ chooseCreateDatabasePath: async () => null,
60
+ chooseOpenDatabasePath: async () => "/tmp/existing-database.db",
61
+ },
62
+ })
63
+ );
64
+ app.use(errorMiddleware);
65
+
66
+ const server = await new Promise((resolve) => {
67
+ const listener = app.listen(0, "127.0.0.1", () => resolve(listener));
68
+ });
69
+
70
+ try {
71
+ const address = server.address();
72
+ const response = await fetch(
73
+ `http://127.0.0.1:${address.port}/api/connections/choose-open-path`,
74
+ { method: "POST" }
75
+ );
76
+ const payload = await response.json();
77
+
78
+ assert.equal(response.status, 200);
79
+ assert.equal(payload.success, true);
80
+ assert.deepEqual(payload.data, {
81
+ cancelled: false,
82
+ path: "/tmp/existing-database.db",
83
+ });
84
+ } finally {
85
+ await new Promise((resolve, reject) => {
86
+ server.close((error) => (error ? reject(error) : resolve()));
87
+ });
88
+ }
89
+ });
@@ -95,3 +95,37 @@ test("create database modal offers a native path picker and manual fallback", as
95
95
  assert.match(html, /name="path"/);
96
96
  assert.match(html, /enter an absolute path manually/);
97
97
  });
98
+
99
+ test("open database modal offers a native file picker and manual fallback", async () => {
100
+ const { renderOpenConnectionForm } = await loadModalModule();
101
+ const html = renderOpenConnectionForm({
102
+ kind: "open-connection",
103
+ error: null,
104
+ submitting: false,
105
+ });
106
+
107
+ assert.match(html, /data-action="choose-open-database-path"/);
108
+ assert.match(html, /data-open-database-path/);
109
+ assert.match(html, /name="path"/);
110
+ assert.match(html, /enter an absolute path manually/);
111
+ });
112
+
113
+ test("API token deletion uses the shared confirmation modal", async () => {
114
+ const { renderDeleteApiTokenForm } = await loadModalModule();
115
+ const html = renderDeleteApiTokenForm({
116
+ kind: "delete-api-token",
117
+ tokenId: "token-one",
118
+ tokenName: "Automation",
119
+ tokenPrefix: "shub_example",
120
+ databaseLabel: "Database One",
121
+ error: null,
122
+ submitting: false,
123
+ });
124
+
125
+ assert.match(html, /Delete API token/);
126
+ assert.match(html, /Automation/);
127
+ assert.match(html, /Database One/);
128
+ assert.match(html, /shub_example\.\.\./);
129
+ assert.match(html, /data-form="delete-api-token-confirm"/);
130
+ assert.match(html, /Delete Token/);
131
+ });
@@ -0,0 +1,102 @@
1
+ const assert = require("node:assert/strict");
2
+ const fs = require("node:fs");
3
+ const os = require("node:os");
4
+ const path = require("node:path");
5
+ const test = require("node:test");
6
+ const Database = require("better-sqlite3");
7
+
8
+ const { DatabaseCommandService } = require("../server/services/databaseCommandService");
9
+ const { AppStateStore } = require("../server/services/storage/appStateStore");
10
+
11
+ function createFixture(t) {
12
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-hub-command-service-"));
13
+ const databasePath = path.join(directory, "sample.db");
14
+ const targetDb = new Database(databasePath);
15
+
16
+ targetDb.exec(`
17
+ CREATE TABLE companies (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
18
+ INSERT INTO companies (name) VALUES ('Acme'), ('Globex');
19
+ `);
20
+ targetDb.close();
21
+
22
+ const store = new AppStateStore(path.join(directory, "state.db"));
23
+ const connection = {
24
+ id: "db-sample",
25
+ label: "Sample",
26
+ path: databasePath,
27
+ lastOpenedAt: new Date().toISOString(),
28
+ lastModifiedAt: new Date().toISOString(),
29
+ sizeBytes: fs.statSync(databasePath).size,
30
+ readOnly: false,
31
+ logoPath: null,
32
+ };
33
+
34
+ store.upsertRecentConnection(connection);
35
+ store.db
36
+ .prepare(
37
+ `
38
+ INSERT INTO query_history (
39
+ database_key,
40
+ normalized_sql,
41
+ raw_sql,
42
+ title,
43
+ notes,
44
+ query_type,
45
+ tables_detected,
46
+ is_saved,
47
+ first_executed_at,
48
+ last_used_at
49
+ )
50
+ VALUES (?, ?, ?, ?, ?, 'select', '["companies"]', 1, ?, ?)
51
+ `
52
+ )
53
+ .run(
54
+ connection.id,
55
+ "select id, name from companies order by id",
56
+ "SELECT id, name FROM companies ORDER BY id",
57
+ "Company List",
58
+ "Used by CLI and API",
59
+ new Date().toISOString(),
60
+ new Date().toISOString()
61
+ );
62
+ store.createDatabaseDocument(connection.id, {
63
+ filename: "Readme.md",
64
+ content: "# Sample\n",
65
+ });
66
+
67
+ t.after(() => {
68
+ store.db.close();
69
+ fs.rmSync(directory, { recursive: true, force: true });
70
+ });
71
+
72
+ return {
73
+ connection,
74
+ service: new DatabaseCommandService({ appStateStore: store }),
75
+ };
76
+ }
77
+
78
+ test("database command service provides shared CLI and API operations", (t) => {
79
+ const { connection, service } = createFixture(t);
80
+
81
+ assert.equal(service.getDatabase("sample").id, connection.id);
82
+ assert.deepEqual(service.listTables(connection.id), [{ name: "companies" }]);
83
+ assert.equal(service.getTable(connection.id, "companies").rowCount, 2);
84
+
85
+ const row = service.getTableRow(connection.id, "companies", "1");
86
+ assert.deepEqual(row.data, { id: 1, name: "Acme" });
87
+ assert.equal(row.identity.kind, "primaryKey");
88
+
89
+ const queries = service.listSavedQueries(connection.id);
90
+ assert.equal(queries.total, 1);
91
+ assert.equal(service.getSavedQuery(connection.id, "Company List").notes, "Used by CLI and API");
92
+
93
+ const execution = service.executeSavedQuery(connection.id, "Company List");
94
+ assert.equal(execution.result.statements[0].rowCount, 2);
95
+
96
+ const exported = service.exportSavedQuery(connection.id, "Company List", "csv");
97
+ assert.equal(exported.result.rowCount, 2);
98
+ assert.match(exported.result.content, /Acme/);
99
+
100
+ assert.equal(service.listDocuments(connection.id).length, 1);
101
+ assert.equal(service.getDocument(connection.id, "Readme").content, "# Sample\n");
102
+ });
@@ -0,0 +1,34 @@
1
+ const assert = require('node:assert/strict');
2
+ const path = require('node:path');
3
+ const { pathToFileURL } = require('node:url');
4
+ const test = require('node:test');
5
+
6
+ let formControlsModulePromise = null;
7
+
8
+ function loadFormControlsModule() {
9
+ if (!formControlsModulePromise) {
10
+ formControlsModulePromise = import(
11
+ pathToFileURL(path.resolve(__dirname, '../frontend/js/components/formControls.js')).href
12
+ );
13
+ }
14
+
15
+ return formControlsModulePromise;
16
+ }
17
+
18
+ test('standard text input uses the shared application styling', async () => {
19
+ const { renderTextInput } = await loadFormControlsModule();
20
+ const markup = renderTextInput({
21
+ className: 'flex-1',
22
+ dataAttributes: { tokenName: true },
23
+ maxlength: 80,
24
+ placeholder: 'Token name',
25
+ });
26
+
27
+ assert.match(markup, /control-input/);
28
+ assert.match(markup, /border-outline-variant\/20/);
29
+ assert.match(markup, /bg-surface-container-lowest/);
30
+ assert.match(markup, /placeholder:text-on-surface-variant\/35/);
31
+ assert.match(markup, /focus:border-primary-container/);
32
+ assert.match(markup, /data-token-name/);
33
+ assert.match(markup, /maxlength="80"/);
34
+ });