sqlite-hub 0.17.0 → 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 (83) 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 +25 -0
  27. package/frontend/js/app.js +183 -26
  28. package/frontend/js/components/formControls.js +38 -0
  29. package/frontend/js/components/modal.js +79 -19
  30. package/frontend/js/components/queryResults.js +18 -1
  31. package/frontend/js/components/rowEditorPanel.js +42 -34
  32. package/frontend/js/store.js +129 -2
  33. package/frontend/js/utils/jsonPreview.js +31 -0
  34. package/frontend/js/utils/rowEditorValues.js +41 -0
  35. package/frontend/js/utils/tableScrollState.js +39 -0
  36. package/frontend/js/views/charts.js +8 -0
  37. package/frontend/js/views/data.js +4 -1
  38. package/frontend/js/views/editor.js +2 -0
  39. package/frontend/js/views/settings.js +141 -5
  40. package/frontend/styles/components.css +6 -0
  41. package/frontend/styles/tailwind.generated.css +2 -2
  42. package/package.json +6 -6
  43. package/server/middleware/apiTokenAuth.js +30 -0
  44. package/server/middleware/localRequestSecurity.js +84 -0
  45. package/server/routes/connections.js +35 -1
  46. package/server/routes/externalApi.js +222 -0
  47. package/server/routes/settings.js +64 -4
  48. package/server/server.js +34 -2
  49. package/server/services/apiTokenService.js +101 -0
  50. package/server/services/databaseCommandService.js +399 -0
  51. package/server/services/nativeFileDialogService.js +260 -0
  52. package/server/services/sqlite/dataBrowserService.js +0 -4
  53. package/server/services/sqlite/exportService.js +5 -1
  54. package/server/services/sqlite/sqlExecutor.js +51 -2
  55. package/server/services/storage/appStateStore.js +113 -0
  56. package/server/utils/errors.js +7 -0
  57. package/server/utils/sqliteTypes.js +17 -8
  58. package/tests/api-token-auth.test.js +127 -0
  59. package/tests/cli-service-delegation.test.js +43 -0
  60. package/tests/connections-file-dialog-route.test.js +89 -0
  61. package/tests/copy-column-modal.test.js +48 -0
  62. package/tests/database-command-service.test.js +102 -0
  63. package/tests/export-blob.test.js +46 -0
  64. package/tests/form-controls.test.js +34 -0
  65. package/tests/json-preview.test.js +49 -0
  66. package/tests/local-request-security.test.js +85 -0
  67. package/tests/native-file-dialog.test.js +105 -0
  68. package/tests/query-results-truncation.test.js +20 -0
  69. package/tests/row-editor-null-values.test.js +131 -0
  70. package/tests/settings-api-tokens-route.test.js +76 -0
  71. package/tests/settings-view.test.js +47 -0
  72. package/tests/sql-identifier-safety.test.js +9 -3
  73. package/tests/sql-result-limit.test.js +66 -0
  74. package/tests/table-scroll-state.test.js +70 -0
  75. package/frontend/assets/mockups/connections.png +0 -0
  76. package/frontend/assets/mockups/data.png +0 -0
  77. package/frontend/assets/mockups/data_row_editor.png +0 -0
  78. package/frontend/assets/mockups/home.png +0 -0
  79. package/frontend/assets/mockups/sql_editor.png +0 -0
  80. package/frontend/assets/mockups/sql_editor_croped.png +0 -0
  81. package/frontend/assets/mockups/sql_editor_querydetail.png +0 -0
  82. package/frontend/assets/mockups/structure.png +0 -0
  83. package/frontend/assets/mockups/structure_inspector.png +0 -0
@@ -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,46 @@
1
+ const Database = require("better-sqlite3");
2
+ const assert = require("node:assert/strict");
3
+ const test = require("node:test");
4
+ const { ExportService } = require("../server/services/sqlite/exportService");
5
+ const { SqlExecutor } = require("../server/services/sqlite/sqlExecutor");
6
+
7
+ test("query and table exports include complete BLOB data", () => {
8
+ const db = new Database(":memory:");
9
+ const blob = Buffer.from(Array.from({ length: 100 }, (_, index) => index));
10
+
11
+ try {
12
+ db.exec("CREATE TABLE files (id INTEGER PRIMARY KEY, payload BLOB)");
13
+ db.prepare("INSERT INTO files (payload) VALUES (?)").run(blob);
14
+
15
+ const connection = { id: "blob-export-test", label: "Blob export" };
16
+ const connectionManager = {
17
+ getActiveConnection: () => connection,
18
+ getActiveDatabase: () => db,
19
+ };
20
+ const appStateStore = {
21
+ findQueryHistoryItemBySql: () => null,
22
+ getSettings: () => ({ csvDelimiter: "," }),
23
+ recordQueryExecution: () => 1,
24
+ };
25
+ const sqlExecutor = new SqlExecutor({ connectionManager, appStateStore });
26
+ const exportService = new ExportService({
27
+ appStateStore,
28
+ connectionManager,
29
+ sqlExecutor,
30
+ });
31
+ const expectedBase64 = blob.toString("base64");
32
+ const queryExport = exportService.exportQuery(
33
+ "SELECT payload FROM files ORDER BY id",
34
+ { format: "csv" }
35
+ );
36
+ const tableExport = exportService.exportTable("files", { format: "tsv" });
37
+
38
+ for (const content of [queryExport.content, tableExport.content]) {
39
+ assert.equal(content.includes(expectedBase64), true);
40
+ assert.match(content, /""encoding"":""base64""/);
41
+ assert.doesNotMatch(content, /base64Preview|hexPreview/);
42
+ }
43
+ } finally {
44
+ db.close();
45
+ }
46
+ });
@@ -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
+ });
@@ -0,0 +1,49 @@
1
+ const assert = require("node:assert/strict");
2
+ const { readFileSync } = require("node:fs");
3
+ const path = require("node:path");
4
+ const test = require("node:test");
5
+
6
+ let jsonPreviewModulePromise = null;
7
+
8
+ function loadJsonPreviewModule() {
9
+ if (!jsonPreviewModulePromise) {
10
+ const source = readFileSync(
11
+ path.resolve(__dirname, "../frontend/js/utils/jsonPreview.js"),
12
+ "utf8"
13
+ );
14
+ const url = `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
15
+
16
+ jsonPreviewModulePromise = import(url);
17
+ }
18
+
19
+ return jsonPreviewModulePromise;
20
+ }
21
+
22
+ test("JSON preview formats objects and arrays with line breaks and indentation", async () => {
23
+ const { formatJsonPreview } = await loadJsonPreviewModule();
24
+
25
+ assert.equal(
26
+ formatJsonPreview('{"event":"created","metadata":{"source":"api"}}'),
27
+ [
28
+ "{",
29
+ ' "event": "created",',
30
+ ' "metadata": {',
31
+ ' "source": "api"',
32
+ " }",
33
+ "}",
34
+ ].join("\n")
35
+ );
36
+ assert.equal(
37
+ formatJsonPreview([1, { active: true }]),
38
+ ["[", " 1,", " {", ' "active": true', " }", "]"].join("\n")
39
+ );
40
+ });
41
+
42
+ test("JSON preview ignores invalid JSON and scalar values", async () => {
43
+ const { formatJsonPreview } = await loadJsonPreviewModule();
44
+
45
+ assert.equal(formatJsonPreview("plain text"), null);
46
+ assert.equal(formatJsonPreview("{invalid"), null);
47
+ assert.equal(formatJsonPreview("42"), null);
48
+ assert.equal(formatJsonPreview(null), null);
49
+ });
@@ -0,0 +1,85 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+ const {
4
+ LOOPBACK_HOST,
5
+ listenOnLoopback,
6
+ localRequestSecurity,
7
+ } = require("../server/middleware/localRequestSecurity");
8
+
9
+ function runMiddleware({ method = "GET", protocol = "http", headers = {} } = {}) {
10
+ const normalizedHeaders = Object.fromEntries(
11
+ Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value])
12
+ );
13
+ const req = {
14
+ method,
15
+ protocol,
16
+ get(name) {
17
+ return normalizedHeaders[String(name).toLowerCase()];
18
+ },
19
+ };
20
+
21
+ return new Promise((resolve) => {
22
+ localRequestSecurity(req, {}, (error) => resolve(error ?? null));
23
+ });
24
+ }
25
+
26
+ test("server listener binds explicitly to the IPv4 loopback address", () => {
27
+ let receivedArguments = null;
28
+ const listener = {};
29
+ const app = {
30
+ listen(...args) {
31
+ receivedArguments = args;
32
+ return listener;
33
+ },
34
+ };
35
+
36
+ assert.equal(listenOnLoopback(app, 4173), listener);
37
+ assert.deepEqual(receivedArguments, [4173, LOOPBACK_HOST]);
38
+ });
39
+
40
+ test("local API security accepts same-origin browser and CLI requests", async () => {
41
+ assert.equal(
42
+ await runMiddleware({
43
+ method: "POST",
44
+ headers: {
45
+ host: "127.0.0.1:4173",
46
+ origin: "http://127.0.0.1:4173",
47
+ "sec-fetch-site": "same-origin",
48
+ },
49
+ }),
50
+ null
51
+ );
52
+ assert.equal(
53
+ await runMiddleware({
54
+ method: "POST",
55
+ headers: { host: "localhost:4173" },
56
+ }),
57
+ null
58
+ );
59
+ });
60
+
61
+ test("local API security rejects foreign hosts and cross-origin mutations", async () => {
62
+ const foreignHostError = await runMiddleware({
63
+ headers: { host: "example.test:4173" },
64
+ });
65
+ const crossOriginError = await runMiddleware({
66
+ method: "POST",
67
+ headers: {
68
+ host: "127.0.0.1:4173",
69
+ origin: "https://example.test",
70
+ "sec-fetch-site": "cross-site",
71
+ },
72
+ });
73
+ const wrongSchemeError = await runMiddleware({
74
+ method: "PATCH",
75
+ headers: {
76
+ host: "localhost:4173",
77
+ origin: "https://localhost:4173",
78
+ },
79
+ });
80
+
81
+ for (const error of [foreignHostError, crossOriginError, wrongSchemeError]) {
82
+ assert.equal(error?.statusCode, 403);
83
+ assert.equal(error?.code, "LOCAL_REQUEST_REQUIRED");
84
+ }
85
+ });
@@ -0,0 +1,105 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+ const {
4
+ NativeFileDialogService,
5
+ buildDialogAttempts,
6
+ buildOpenDialogAttempts,
7
+ normalizeOpenedDatabasePath,
8
+ normalizeSelectedDatabasePath,
9
+ } = require("../server/services/nativeFileDialogService");
10
+
11
+ test("native database dialog normalizes paths and adds a default extension", () => {
12
+ assert.equal(normalizeSelectedDatabasePath("/tmp/customer-data"), "/tmp/customer-data.sqlite");
13
+ assert.equal(normalizeSelectedDatabasePath("/tmp/customer-data.db\n"), "/tmp/customer-data.db");
14
+ assert.equal(normalizeSelectedDatabasePath(""), null);
15
+ });
16
+
17
+ test("native database dialog builds platform-specific save commands", () => {
18
+ const macAttempt = buildDialogAttempts({ platform: "darwin", homeDirectory: "/Users/test" })[0];
19
+ const windowsAttempt = buildDialogAttempts({ platform: "win32", homeDirectory: "C:\\Users\\test" })[0];
20
+ const linuxAttempts = buildDialogAttempts({ platform: "linux", homeDirectory: "/home/test" });
21
+
22
+ assert.equal(macAttempt.command, "osascript");
23
+ assert.match(macAttempt.args.join(" "), /choose file name/);
24
+ assert.equal(windowsAttempt.command, "powershell.exe");
25
+ assert.match(windowsAttempt.args.at(-1), /SaveFileDialog/);
26
+ assert.deepEqual(linuxAttempts.map((attempt) => attempt.command), ["zenity", "kdialog"]);
27
+ });
28
+
29
+ test("native database dialog builds platform-specific open commands", () => {
30
+ const macAttempt = buildOpenDialogAttempts({ platform: "darwin", homeDirectory: "/Users/test" })[0];
31
+ const windowsAttempt = buildOpenDialogAttempts({ platform: "win32", homeDirectory: "C:\\Users\\test" })[0];
32
+ const linuxAttempts = buildOpenDialogAttempts({ platform: "linux", homeDirectory: "/home/test" });
33
+
34
+ assert.match(macAttempt.args.join(" "), /choose file with prompt/);
35
+ assert.match(windowsAttempt.args.at(-1), /OpenFileDialog/);
36
+ assert.deepEqual(linuxAttempts.map((attempt) => attempt.command), ["zenity", "kdialog"]);
37
+ });
38
+
39
+ test("open database dialog preserves the selected filename", () => {
40
+ assert.equal(normalizeOpenedDatabasePath("/tmp/catalog\n"), "/tmp/catalog");
41
+ assert.equal(normalizeOpenedDatabasePath(""), null);
42
+ });
43
+
44
+ test("native database dialog returns null when the user cancels", async () => {
45
+ const service = new NativeFileDialogService({
46
+ platform: "darwin",
47
+ homeDirectory: "/Users/test",
48
+ executeFile: async () => {
49
+ const error = new Error("User canceled.");
50
+ error.code = 1;
51
+ throw error;
52
+ },
53
+ });
54
+
55
+ assert.equal(await service.chooseCreateDatabasePath(), null);
56
+ });
57
+
58
+ test("native database dialog does not hide macOS script failures as cancellations", async () => {
59
+ const service = new NativeFileDialogService({
60
+ platform: "darwin",
61
+ homeDirectory: "/Users/test",
62
+ executeFile: async () => {
63
+ const error = new Error("AppleScript syntax error");
64
+ error.code = 1;
65
+ throw error;
66
+ },
67
+ });
68
+
69
+ await assert.rejects(service.chooseCreateDatabasePath(), (error) => {
70
+ assert.equal(error.code, "NATIVE_FILE_DIALOG_FAILED");
71
+ return true;
72
+ });
73
+ });
74
+
75
+ test("native database dialog falls back from zenity to kdialog", async () => {
76
+ const commands = [];
77
+ const service = new NativeFileDialogService({
78
+ platform: "linux",
79
+ homeDirectory: "/home/test",
80
+ executeFile: async (command) => {
81
+ commands.push(command);
82
+
83
+ if (command === "zenity") {
84
+ const error = new Error("not found");
85
+ error.code = "ENOENT";
86
+ throw error;
87
+ }
88
+
89
+ return { stdout: "/home/test/catalog.sqlite3\n" };
90
+ },
91
+ });
92
+
93
+ assert.equal(await service.chooseCreateDatabasePath(), "/home/test/catalog.sqlite3");
94
+ assert.deepEqual(commands, ["zenity", "kdialog"]);
95
+ });
96
+
97
+ test("native open database dialog returns the selected existing path", async () => {
98
+ const service = new NativeFileDialogService({
99
+ platform: "linux",
100
+ homeDirectory: "/home/test",
101
+ executeFile: async () => ({ stdout: "/home/test/catalog.db\n" }),
102
+ });
103
+
104
+ assert.equal(await service.chooseOpenDatabasePath(), "/home/test/catalog.db");
105
+ });
@@ -0,0 +1,20 @@
1
+ const assert = require("node:assert/strict");
2
+ const path = require("node:path");
3
+ const test = require("node:test");
4
+ const { pathToFileURL } = require("node:url");
5
+
6
+ test("query results render an explicit truncation notice", async () => {
7
+ const moduleUrl = pathToFileURL(
8
+ path.resolve(__dirname, "../frontend/js/components/queryResults.js")
9
+ ).href;
10
+ const { renderQueryResultsPane } = await import(moduleUrl);
11
+ const html = renderQueryResultsPane({
12
+ columns: ["id"],
13
+ rows: [{ id: 1 }, { id: 2 }],
14
+ truncated: true,
15
+ rowLimit: 2,
16
+ });
17
+
18
+ assert.match(html, /Showing the first 2 rows/);
19
+ assert.match(html, /export it to process the complete result set/);
20
+ });
@@ -0,0 +1,131 @@
1
+ const Database = require("better-sqlite3");
2
+ const assert = require("node:assert/strict");
3
+ const path = require("node:path");
4
+ const test = require("node:test");
5
+ const { pathToFileURL } = require("node:url");
6
+ const { DataBrowserService } = require("../server/services/sqlite/dataBrowserService");
7
+
8
+ async function loadFrontendModule(relativePath) {
9
+ return import(pathToFileURL(path.resolve(__dirname, relativePath)).href);
10
+ }
11
+
12
+ test("row editor values preserve NULL and empty string as distinct values", async () => {
13
+ const {
14
+ buildRowEditorSubmittedValues,
15
+ getRowEditorValueState,
16
+ getRowEditorValueStateLabel,
17
+ } = await loadFrontendModule("../frontend/js/utils/rowEditorValues.js");
18
+ const formData = new FormData();
19
+
20
+ formData.append("field:title", "");
21
+ formData.append("field:description", "");
22
+ formData.append("field:count", "42");
23
+
24
+ assert.deepEqual(buildRowEditorSubmittedValues(formData, {
25
+ title: { initialState: "empty", dirty: false },
26
+ description: { initialState: "null", dirty: false },
27
+ }), {
28
+ title: "",
29
+ description: null,
30
+ count: "42",
31
+ });
32
+ assert.equal(
33
+ buildRowEditorSubmittedValues(formData, {
34
+ description: { initialState: "null", dirty: true },
35
+ }).description,
36
+ ""
37
+ );
38
+ assert.equal(getRowEditorValueState(null), "null");
39
+ assert.equal(getRowEditorValueState(""), "empty");
40
+ assert.equal(getRowEditorValueState("text"), "value");
41
+ assert.equal(getRowEditorValueStateLabel("null"), "NULL");
42
+ assert.equal(getRowEditorValueStateLabel("empty"), "EMPTY STRING");
43
+ });
44
+
45
+ test("row editor renders visible NULL and empty-string states", async () => {
46
+ const { renderRowEditorPanel } = await loadFrontendModule(
47
+ "../frontend/js/components/rowEditorPanel.js"
48
+ );
49
+ const html = renderRowEditorPanel({
50
+ title: "Values",
51
+ closeAction: "close",
52
+ formName: "save-data-row",
53
+ editableFields: [
54
+ {
55
+ name: "nullable_text",
56
+ label: "nullable_text",
57
+ rawValue: null,
58
+ value: "",
59
+ notNull: false,
60
+ },
61
+ {
62
+ name: "empty_text",
63
+ label: "empty_text",
64
+ rawValue: "",
65
+ value: "",
66
+ notNull: false,
67
+ },
68
+ {
69
+ name: "required_text",
70
+ label: "required_text",
71
+ rawValue: "value",
72
+ value: "value",
73
+ notNull: true,
74
+ },
75
+ ],
76
+ });
77
+
78
+ assert.match(html, /data-value-state="null"[^>]*>NULL</s);
79
+ assert.match(html, /data-value-state="empty"[^>]*>EMPTY STRING</s);
80
+ assert.match(html, /data-value-state="value"[^>]*>VALUE</s);
81
+ assert.doesNotMatch(html, /field-null:|row-editor-null-toggle|Set NULL/);
82
+ assert.doesNotMatch(html, /name="field:nullable_text"[\s\S]*?disabled/);
83
+ });
84
+
85
+ test("data updates can change NULL to empty string and empty string to NULL", () => {
86
+ const db = new Database(":memory:");
87
+
88
+ try {
89
+ db.exec(`
90
+ CREATE TABLE values_test (
91
+ id INTEGER PRIMARY KEY,
92
+ nullable_text TEXT,
93
+ empty_text TEXT
94
+ );
95
+ INSERT INTO values_test (id, nullable_text, empty_text) VALUES (1, NULL, '');
96
+ `);
97
+ const service = new DataBrowserService({
98
+ connectionManager: {
99
+ assertWritable() {},
100
+ getActiveDatabase: () => db,
101
+ },
102
+ });
103
+ const identity = service.getTableData("values_test", { limit: 10 }).rows[0].__identity;
104
+ const preview = service.previewTableRowUpdate("values_test", {
105
+ identity,
106
+ values: {
107
+ nullable_text: "",
108
+ empty_text: null,
109
+ },
110
+ });
111
+
112
+ assert.deepEqual(preview.changes, [
113
+ { column: "nullable_text", oldValue: "NULL", newValue: "" },
114
+ { column: "empty_text", oldValue: "", newValue: "NULL" },
115
+ ]);
116
+
117
+ service.updateTableRow("values_test", {
118
+ identity,
119
+ values: {
120
+ nullable_text: "",
121
+ empty_text: null,
122
+ },
123
+ });
124
+ const row = db.prepare("SELECT nullable_text, empty_text FROM values_test WHERE id = 1").get();
125
+
126
+ assert.equal(row.nullable_text, "");
127
+ assert.equal(row.empty_text, null);
128
+ } finally {
129
+ db.close();
130
+ }
131
+ });
@@ -0,0 +1,76 @@
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 { createSettingsRouter } = require("../server/routes/settings");
9
+ const { ApiTokenService } = require("../server/services/apiTokenService");
10
+ const { AppStateStore } = require("../server/services/storage/appStateStore");
11
+ const { errorMiddleware } = require("../server/utils/errors");
12
+
13
+ test("settings routes create and delete tokens for the active database", async (t) => {
14
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-hub-settings-token-"));
15
+ const store = new AppStateStore(path.join(directory, "state.db"));
16
+ const connection = {
17
+ id: "db-active",
18
+ label: "Active Database",
19
+ path: path.join(directory, "active.db"),
20
+ lastOpenedAt: new Date().toISOString(),
21
+ lastModifiedAt: new Date().toISOString(),
22
+ sizeBytes: 0,
23
+ readOnly: false,
24
+ logoPath: null,
25
+ };
26
+
27
+ store.upsertRecentConnection(connection);
28
+
29
+ const tokenService = new ApiTokenService({ appStateStore: store });
30
+ const connectionManager = {
31
+ getActiveConnection: () => connection,
32
+ };
33
+ const app = express();
34
+
35
+ app.use(express.json());
36
+ app.use(
37
+ "/api/settings",
38
+ createSettingsRouter({ appStateStore: store, connectionManager, tokenService })
39
+ );
40
+ app.use(errorMiddleware);
41
+
42
+ const server = await new Promise((resolve) => {
43
+ const listener = app.listen(0, "127.0.0.1", () => resolve(listener));
44
+ });
45
+
46
+ t.after(async () => {
47
+ await new Promise((resolve, reject) => {
48
+ server.close((error) => (error ? reject(error) : resolve()));
49
+ });
50
+ store.db.close();
51
+ fs.rmSync(directory, { recursive: true, force: true });
52
+ });
53
+
54
+ const baseUrl = `http://127.0.0.1:${server.address().port}/api/settings`;
55
+ const createResponse = await fetch(`${baseUrl}/api-tokens`, {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json" },
58
+ body: JSON.stringify({ name: "Settings token" }),
59
+ });
60
+ const created = await createResponse.json();
61
+
62
+ assert.equal(createResponse.status, 201);
63
+ assert.equal(created.data.databaseKey, connection.id);
64
+ assert.match(created.data.token, /^shub_/);
65
+ assert.equal(created.metadata.apiTokens.length, 1);
66
+ assert.equal(created.metadata.apiTokens[0].token, undefined);
67
+
68
+ const deleteResponse = await fetch(`${baseUrl}/api-tokens/${created.data.id}`, {
69
+ method: "DELETE",
70
+ });
71
+ const deleted = await deleteResponse.json();
72
+
73
+ assert.equal(deleteResponse.status, 200);
74
+ assert.deepEqual(deleted.data, { id: created.data.id, deleted: true });
75
+ assert.equal(deleted.metadata.apiTokens.length, 0);
76
+ });