sqlite-hub 1.2.0 → 1.5.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.
- package/README.md +113 -71
- package/bin/sqlite-hub.js +723 -116
- package/docs/API.md +152 -0
- package/docs/CLI.md +249 -0
- package/docs/CLI_API_PARITY.md +61 -0
- package/docs/changelog.md +149 -0
- package/docs/guidelines/AGENTS.md +32 -0
- package/docs/guidelines/DESIGN.md +55 -0
- package/docs/shortkeys.md +27 -0
- package/docs/todo.md +4 -0
- package/examples/api/generate-types.js +52 -0
- package/frontend/assets/mockups/backups_1_1920.webp +0 -0
- package/frontend/assets/mockups/backups_2_create_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/backups_3_edit_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/backups_4_restore_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/backups_5_delete_backup_modal_1920.webp +0 -0
- package/frontend/assets/mockups/charts_1_1920.webp +0 -0
- package/frontend/assets/mockups/charts_2_query_detail_1920.webp +0 -0
- package/frontend/assets/mockups/charts_3_create_query_chart_modal_1920.webp +0 -0
- package/frontend/assets/mockups/charts_4_edit_query_chart_modal_1920.webp +0 -0
- package/frontend/assets/mockups/charts_5_delete_query_chart_modal_1920.webp +0 -0
- package/frontend/assets/mockups/charts_6_copy_column_modal_1920.webp +0 -0
- package/frontend/assets/mockups/connections_1_1920.webp +0 -0
- package/frontend/assets/mockups/connections_2_create_connection_modal_1920.webp +0 -0
- package/frontend/assets/mockups/connections_3_open_connection_modal_1920.webp +0 -0
- package/frontend/assets/mockups/data_1_1920.webp +0 -0
- package/frontend/assets/mockups/data_2_roweditor_1920.webp +0 -0
- package/frontend/assets/mockups/data_3_data_export_modal_1920.webp +0 -0
- package/frontend/assets/mockups/documents_1_1920.webp +0 -0
- package/frontend/assets/mockups/documents_2_document_insert_table_modal_1920.webp +0 -0
- package/frontend/assets/mockups/documents_3_document_insert_note_modal_1920.webp +0 -0
- package/frontend/assets/mockups/media_tagging_queue_1_1920.webp +0 -0
- package/frontend/assets/mockups/media_tagging_setup_1_1920.webp +0 -0
- package/frontend/assets/mockups/media_tagging_setup_2_create_media_tagging_tag_table_modal_1920.webp +0 -0
- package/frontend/assets/mockups/media_tagging_setup_3_create_media_tagging_mapping_table_modal_1920.webp +0 -0
- package/frontend/assets/mockups/overview_1_1920.webp +0 -0
- package/frontend/assets/mockups/settings_1_1920.webp +0 -0
- package/frontend/assets/mockups/sql_editor_1_1920.webp +0 -0
- package/frontend/assets/mockups/sql_editor_2_query_detail_1920.webp +0 -0
- package/frontend/assets/mockups/sql_editor_3_query_export_modal_1920.webp +0 -0
- package/frontend/assets/mockups/structure_1_1920.webp +0 -0
- package/frontend/assets/mockups/structure_2_generate_types_modal_1920.webp +0 -0
- package/frontend/assets/mockups/structure_3_generate_types_modal_1920.webp +0 -0
- package/frontend/assets/mockups/table_designer_1_1920.webp +0 -0
- package/frontend/assets/mockups/table_designer_2_table_designer_constraints_modal_1920.webp +0 -0
- package/frontend/js/api.js +63 -0
- package/frontend/js/app.js +310 -10
- package/frontend/js/components/connectionCard.js +4 -2
- package/frontend/js/components/emptyState.js +4 -4
- package/frontend/js/components/generateTypesDropdown.js +33 -0
- package/frontend/js/components/metricCard.js +1 -1
- package/frontend/js/components/modal.js +555 -21
- package/frontend/js/components/pageHeader.js +1 -1
- package/frontend/js/components/queryHistoryDetail.js +3 -3
- package/frontend/js/components/queryHistoryHeader.js +1 -1
- package/frontend/js/components/queryHistoryPanel.js +1 -1
- package/frontend/js/components/rowEditorPanel.js +13 -3
- package/frontend/js/components/sidebar.js +41 -7
- package/frontend/js/components/topNav.js +2 -14
- package/frontend/js/router.js +38 -6
- package/frontend/js/store.js +870 -8
- package/frontend/js/utils/inputClear.js +36 -0
- package/frontend/js/utils/syntheticData.js +240 -0
- package/frontend/js/views/backups.js +581 -37
- package/frontend/js/views/charts.js +9 -9
- package/frontend/js/views/connections.js +2 -2
- package/frontend/js/views/data.js +17 -1
- package/frontend/js/views/documents.js +3 -3
- package/frontend/js/views/editor.js +6 -6
- package/frontend/js/views/logs.js +339 -0
- package/frontend/js/views/mediaTagging.js +7 -5
- package/frontend/js/views/overview.js +3 -3
- package/frontend/js/views/settings.js +80 -30
- package/frontend/js/views/structure.js +12 -1
- package/frontend/js/views/tableAdvisor.js +385 -0
- package/frontend/js/views/tableDesigner.js +6 -0
- package/frontend/styles/base.css +1 -40
- package/frontend/styles/components.css +258 -232
- package/frontend/styles/structure-graph.css +32 -68
- package/frontend/styles/tailwind.css +8 -4
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/tokens.css +28 -21
- package/frontend/styles/views.css +102 -261
- package/package.json +18 -2
- package/server/routes/backups.js +18 -2
- package/server/routes/data.js +45 -0
- package/server/routes/externalApi.js +321 -2
- package/server/routes/logs.js +127 -0
- package/server/routes/structure.js +22 -0
- package/server/server.js +3 -0
- package/server/services/databaseCommandService.js +59 -0
- package/server/services/sqlite/backupDiff.js +914 -0
- package/server/services/sqlite/backupService.js +77 -1
- package/server/services/sqlite/dataBrowserService.js +36 -0
- package/server/services/sqlite/introspection.js +226 -22
- package/server/services/sqlite/structureService.js +7 -0
- package/server/services/sqlite/syntheticDataGenerator.js +728 -0
- package/server/services/sqlite/tableAdvisor.js +790 -0
- package/server/services/storage/appStateStore.js +773 -169
- package/server/services/typeGenerationService.js +663 -0
- package/tailwind.config.cjs +0 -1
- package/.github/funding.yml +0 -2
- package/.github/workflows/ci.yml +0 -36
- package/database.sqlite +0 -0
- package/frontend/assets/mockups/charts_1_bars_1200.webp +0 -0
- package/frontend/assets/mockups/charts_2_pie_1200.webp +0 -0
- package/frontend/assets/mockups/charts_3_scatter_plot_1200.webp +0 -0
- package/frontend/assets/mockups/connections_1200.webp +0 -0
- package/frontend/assets/mockups/data_1_1200.webp +0 -0
- package/frontend/assets/mockups/data_2_row_editor_1200.webp +0 -0
- package/frontend/assets/mockups/documents_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_1_setup_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_2_tagging_queue_1200.webp +0 -0
- package/frontend/assets/mockups/media_tagging_3_media_viewer_1200.webp +0 -0
- package/frontend/assets/mockups/overview_1200.webp +0 -0
- package/frontend/assets/mockups/settings_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_1_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_2_query_details_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_3_export_column_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_4_export_column_as_markdown_1200.webp +0 -0
- package/frontend/assets/mockups/sql_editor_5_export_query_result_1200.webp +0 -0
- package/frontend/assets/mockups/structure_1_1200.webp +0 -0
- package/frontend/assets/mockups/structure_2_inspector_1200.webp +0 -0
- package/frontend/assets/mockups/table_designer_1_1200.webp +0 -0
- package/frontend/assets/mockups/table_designer_2_checks_1200.webp +0 -0
- package/tests/api-token-auth.test.js +0 -279
- package/tests/backup-manager.test.js +0 -140
- package/tests/backups-view.test.js +0 -70
- package/tests/charts-height-preset-storage.test.js +0 -60
- package/tests/charts-route-state.test.js +0 -144
- package/tests/check-constraint-options.test.js +0 -90
- package/tests/cli-args.test.js +0 -113
- package/tests/cli-service-delegation.test.js +0 -140
- package/tests/connection-removal.test.js +0 -52
- package/tests/connections-file-dialog-route.test.js +0 -89
- package/tests/copy-column-modal.test.js +0 -131
- package/tests/database-command-service.test.js +0 -174
- package/tests/database-documents.test.js +0 -85
- package/tests/documents-view.test.js +0 -132
- package/tests/dropdown-button.test.js +0 -101
- package/tests/email-preview.test.js +0 -89
- package/tests/export-blob.test.js +0 -99
- package/tests/export-filenames.test.js +0 -38
- package/tests/file-path-preview.test.js +0 -165
- package/tests/form-controls.test.js +0 -34
- package/tests/json-preview.test.js +0 -49
- package/tests/local-request-security.test.js +0 -85
- package/tests/markdown-documents.test.js +0 -103
- package/tests/native-file-dialog.test.js +0 -105
- package/tests/query-editor.test.js +0 -28
- package/tests/query-history-detail.test.js +0 -37
- package/tests/query-history-header.test.js +0 -30
- package/tests/query-results-truncation.test.js +0 -20
- package/tests/risky-sql.test.js +0 -30
- package/tests/row-editor-json.test.js +0 -82
- package/tests/row-editor-null-values.test.js +0 -184
- package/tests/row-editor-timestamp-preview.test.js +0 -192
- package/tests/security-paths.test.js +0 -84
- package/tests/settings-api-tokens-route.test.js +0 -97
- package/tests/settings-metadata.test.js +0 -114
- package/tests/settings-view.test.js +0 -108
- package/tests/sql-formatter.test.js +0 -173
- package/tests/sql-highlight.test.js +0 -38
- package/tests/sql-identifier-safety.test.js +0 -171
- package/tests/sql-result-limit.test.js +0 -66
- package/tests/structure-view.test.js +0 -60
- package/tests/table-designer-v2-unique-constraints.test.js +0 -78
- package/tests/table-scroll-state.test.js +0 -70
- package/tests/text-cell-stats.test.js +0 -38
package/.github/workflows/ci.yml
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
pull_request:
|
|
5
|
-
push:
|
|
6
|
-
branches:
|
|
7
|
-
- main
|
|
8
|
-
|
|
9
|
-
permissions:
|
|
10
|
-
contents: read
|
|
11
|
-
|
|
12
|
-
jobs:
|
|
13
|
-
build-test-audit:
|
|
14
|
-
runs-on: ubuntu-latest
|
|
15
|
-
|
|
16
|
-
steps:
|
|
17
|
-
- name: Checkout
|
|
18
|
-
uses: actions/checkout@v4
|
|
19
|
-
|
|
20
|
-
- name: Setup Node
|
|
21
|
-
uses: actions/setup-node@v4
|
|
22
|
-
with:
|
|
23
|
-
node-version: 26
|
|
24
|
-
cache: npm
|
|
25
|
-
|
|
26
|
-
- name: Install dependencies
|
|
27
|
-
run: npm ci
|
|
28
|
-
|
|
29
|
-
- name: Build
|
|
30
|
-
run: npm run build
|
|
31
|
-
|
|
32
|
-
- name: Test
|
|
33
|
-
run: npm test
|
|
34
|
-
|
|
35
|
-
- name: Dependency audit
|
|
36
|
-
run: npm run audit
|
package/database.sqlite
DELETED
|
File without changes
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,279 +0,0 @@
|
|
|
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 { ReadOnlyError } = require("../server/utils/errors");
|
|
12
|
-
const { errorMiddleware } = require("../server/utils/errors");
|
|
13
|
-
|
|
14
|
-
function createConnection(id, label, databasePath) {
|
|
15
|
-
return {
|
|
16
|
-
id,
|
|
17
|
-
label,
|
|
18
|
-
path: databasePath,
|
|
19
|
-
lastOpenedAt: new Date().toISOString(),
|
|
20
|
-
lastModifiedAt: new Date().toISOString(),
|
|
21
|
-
sizeBytes: 0,
|
|
22
|
-
readOnly: false,
|
|
23
|
-
logoPath: null,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async function startApi(t) {
|
|
28
|
-
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-hub-api-token-"));
|
|
29
|
-
const store = new AppStateStore(path.join(directory, "state.db"));
|
|
30
|
-
const databaseA = createConnection("db-a", "Database A", path.join(directory, "a.db"));
|
|
31
|
-
const databaseB = createConnection("db-b", "Database B", path.join(directory, "b.db"));
|
|
32
|
-
|
|
33
|
-
store.upsertRecentConnection(databaseA);
|
|
34
|
-
store.upsertRecentConnection(databaseB, { makeActive: false });
|
|
35
|
-
|
|
36
|
-
const tokenService = new ApiTokenService({ appStateStore: store });
|
|
37
|
-
const serviceCalls = [];
|
|
38
|
-
const databaseService = {
|
|
39
|
-
listTables(databaseId) {
|
|
40
|
-
serviceCalls.push(databaseId);
|
|
41
|
-
return [{ name: "companies" }];
|
|
42
|
-
},
|
|
43
|
-
executeRawQuery(databaseId, sql, options = {}) {
|
|
44
|
-
serviceCalls.push(
|
|
45
|
-
`${databaseId}:query:${sql}:${options.storeName ?? ""}:${options.executedBy ?? ""}`
|
|
46
|
-
);
|
|
47
|
-
if (sql === "READONLY") {
|
|
48
|
-
throw new ReadOnlyError("Cannot execute raw SQL against a read-only database.");
|
|
49
|
-
}
|
|
50
|
-
return {
|
|
51
|
-
result: {
|
|
52
|
-
sql,
|
|
53
|
-
statementCount: 1,
|
|
54
|
-
statements: [],
|
|
55
|
-
rows: [],
|
|
56
|
-
columns: [],
|
|
57
|
-
affectedRowCount: 0,
|
|
58
|
-
resultKind: "unknown",
|
|
59
|
-
timingMs: 2,
|
|
60
|
-
historyId: 42,
|
|
61
|
-
storedQuery: options.storeName
|
|
62
|
-
? {
|
|
63
|
-
id: 42,
|
|
64
|
-
title: options.storeName,
|
|
65
|
-
isSaved: true,
|
|
66
|
-
}
|
|
67
|
-
: null,
|
|
68
|
-
},
|
|
69
|
-
};
|
|
70
|
-
},
|
|
71
|
-
executeSavedQuery(databaseId, queryName, options = {}) {
|
|
72
|
-
serviceCalls.push(`${databaseId}:saved:${queryName}:${options.executedBy ?? ""}`);
|
|
73
|
-
return {
|
|
74
|
-
query: {
|
|
75
|
-
id: 7,
|
|
76
|
-
title: queryName,
|
|
77
|
-
rawSql: "SELECT 1",
|
|
78
|
-
},
|
|
79
|
-
result: {
|
|
80
|
-
sql: "SELECT 1",
|
|
81
|
-
statementCount: 1,
|
|
82
|
-
statements: [],
|
|
83
|
-
rows: [],
|
|
84
|
-
columns: [],
|
|
85
|
-
affectedRowCount: 0,
|
|
86
|
-
resultKind: "resultSet",
|
|
87
|
-
timingMs: 3,
|
|
88
|
-
historyId: 7,
|
|
89
|
-
},
|
|
90
|
-
};
|
|
91
|
-
},
|
|
92
|
-
};
|
|
93
|
-
const app = express();
|
|
94
|
-
|
|
95
|
-
app.use(express.json());
|
|
96
|
-
app.use(
|
|
97
|
-
"/api/v1",
|
|
98
|
-
createExternalApiRouter({
|
|
99
|
-
databaseService,
|
|
100
|
-
tokenService,
|
|
101
|
-
appInfoService: async ({ port, url }) => ({
|
|
102
|
-
packageName: "sqlite-hub",
|
|
103
|
-
appVersion: "1.0.1",
|
|
104
|
-
sqliteVersion: "3.50.0",
|
|
105
|
-
port,
|
|
106
|
-
url,
|
|
107
|
-
versionCheck: {
|
|
108
|
-
packageName: "sqlite-hub",
|
|
109
|
-
currentVersion: "1.0.1",
|
|
110
|
-
latestVersion: "1.0.1",
|
|
111
|
-
updateAvailable: false,
|
|
112
|
-
checkedAt: "2026-06-20T10:00:00.000Z",
|
|
113
|
-
source: "npm",
|
|
114
|
-
releaseUrl: "https://www.npmjs.com/package/sqlite-hub/v/1.0.1",
|
|
115
|
-
status: "current",
|
|
116
|
-
},
|
|
117
|
-
}),
|
|
118
|
-
})
|
|
119
|
-
);
|
|
120
|
-
app.use(errorMiddleware);
|
|
121
|
-
|
|
122
|
-
const server = await new Promise((resolve) => {
|
|
123
|
-
const listener = app.listen(0, "127.0.0.1", () => resolve(listener));
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
t.after(async () => {
|
|
127
|
-
await new Promise((resolve, reject) => {
|
|
128
|
-
server.close((error) => (error ? reject(error) : resolve()));
|
|
129
|
-
});
|
|
130
|
-
store.db.close();
|
|
131
|
-
fs.rmSync(directory, { recursive: true, force: true });
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
return {
|
|
135
|
-
baseUrl: `http://127.0.0.1:${server.address().port}/api/v1`,
|
|
136
|
-
databaseA,
|
|
137
|
-
databaseB,
|
|
138
|
-
serviceCalls,
|
|
139
|
-
store,
|
|
140
|
-
tokenService,
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
test("tokens are hashed, deletable, and isolated per database", async (t) => {
|
|
145
|
-
const fixture = await startApi(t);
|
|
146
|
-
const created = fixture.tokenService.createToken(fixture.databaseA.id, "Automation");
|
|
147
|
-
|
|
148
|
-
assert.match(created.token, /^shub_[A-Za-z0-9_-]+$/);
|
|
149
|
-
assert.equal(fixture.tokenService.listTokens(fixture.databaseA.id)[0].token, undefined);
|
|
150
|
-
|
|
151
|
-
const stored = fixture.store.db
|
|
152
|
-
.prepare("SELECT token_hash, token_prefix FROM api_tokens WHERE id = ?")
|
|
153
|
-
.get(created.id);
|
|
154
|
-
|
|
155
|
-
assert.notEqual(stored.token_hash, created.token);
|
|
156
|
-
assert.equal(stored.token_prefix, created.tokenPrefix);
|
|
157
|
-
assert.equal(fixture.tokenService.authenticate(fixture.databaseA.id, created.token).id, created.id);
|
|
158
|
-
assert.throws(
|
|
159
|
-
() => fixture.tokenService.authenticate(fixture.databaseB.id, created.token),
|
|
160
|
-
/invalid for this database/
|
|
161
|
-
);
|
|
162
|
-
|
|
163
|
-
const validResponse = await fetch(
|
|
164
|
-
`${fixture.baseUrl}/databases/${fixture.databaseA.id}/tables`,
|
|
165
|
-
{ headers: { Authorization: `Bearer ${created.token}` } }
|
|
166
|
-
);
|
|
167
|
-
const validPayload = await validResponse.json();
|
|
168
|
-
|
|
169
|
-
assert.equal(validResponse.status, 200);
|
|
170
|
-
assert.deepEqual(validPayload.data.items, [{ name: "companies" }]);
|
|
171
|
-
assert.deepEqual(fixture.serviceCalls, [fixture.databaseA.id]);
|
|
172
|
-
|
|
173
|
-
const invalidResponse = await fetch(
|
|
174
|
-
`${fixture.baseUrl}/databases/${fixture.databaseA.id}/tables`,
|
|
175
|
-
{ headers: { Authorization: "Bearer invalid" } }
|
|
176
|
-
);
|
|
177
|
-
assert.equal(invalidResponse.status, 401);
|
|
178
|
-
|
|
179
|
-
const missingResponse = await fetch(
|
|
180
|
-
`${fixture.baseUrl}/databases/${fixture.databaseA.id}/tables`
|
|
181
|
-
);
|
|
182
|
-
assert.equal(missingResponse.status, 401);
|
|
183
|
-
|
|
184
|
-
const wrongDatabaseResponse = await fetch(
|
|
185
|
-
`${fixture.baseUrl}/databases/${fixture.databaseB.id}/tables`,
|
|
186
|
-
{ headers: { Authorization: `Bearer ${created.token}` } }
|
|
187
|
-
);
|
|
188
|
-
assert.equal(wrongDatabaseResponse.status, 401);
|
|
189
|
-
assert.deepEqual(fixture.serviceCalls, [fixture.databaseA.id]);
|
|
190
|
-
|
|
191
|
-
fixture.tokenService.deleteToken(fixture.databaseA.id, created.id);
|
|
192
|
-
assert.equal(fixture.tokenService.listTokens(fixture.databaseA.id).length, 0);
|
|
193
|
-
assert.throws(
|
|
194
|
-
() => fixture.tokenService.authenticate(fixture.databaseA.id, created.token),
|
|
195
|
-
/invalid for this database/
|
|
196
|
-
);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
test("public API info returns app and version status without a token", async (t) => {
|
|
200
|
-
const fixture = await startApi(t);
|
|
201
|
-
const response = await fetch(`${fixture.baseUrl}/info`);
|
|
202
|
-
const payload = await response.json();
|
|
203
|
-
|
|
204
|
-
assert.equal(response.status, 200);
|
|
205
|
-
assert.equal(payload.data.packageName, "sqlite-hub");
|
|
206
|
-
assert.equal(payload.data.appVersion, "1.0.1");
|
|
207
|
-
assert.equal(payload.data.sqliteVersion, "3.50.0");
|
|
208
|
-
assert.equal(payload.data.versionCheck.status, "current");
|
|
209
|
-
assert.equal(payload.data.versionCheck.updateAvailable, false);
|
|
210
|
-
assert.match(payload.data.url, /^http:\/\/127\.0\.0\.1:\d+$/);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
test("query API executes raw SQL with a database token", async (t) => {
|
|
214
|
-
const fixture = await startApi(t);
|
|
215
|
-
const created = fixture.tokenService.createToken(fixture.databaseA.id, "Automation");
|
|
216
|
-
const response = await fetch(`${fixture.baseUrl}/query`, {
|
|
217
|
-
method: "POST",
|
|
218
|
-
headers: {
|
|
219
|
-
Authorization: `Bearer ${created.token}`,
|
|
220
|
-
"Content-Type": "application/json",
|
|
221
|
-
},
|
|
222
|
-
body: JSON.stringify({
|
|
223
|
-
databaseId: fixture.databaseA.id,
|
|
224
|
-
sql: "SELECT 1",
|
|
225
|
-
store: "Stored API Query",
|
|
226
|
-
}),
|
|
227
|
-
});
|
|
228
|
-
const payload = await response.json();
|
|
229
|
-
|
|
230
|
-
assert.equal(response.status, 200);
|
|
231
|
-
assert.equal(payload.data.sql, "SELECT 1");
|
|
232
|
-
assert.equal(payload.data.historyId, 42);
|
|
233
|
-
assert.equal(payload.data.storedQuery.title, "Stored API Query");
|
|
234
|
-
assert.equal(payload.metadata.stored, true);
|
|
235
|
-
assert.equal(payload.metadata.databaseId, fixture.databaseA.id);
|
|
236
|
-
assert.deepEqual(fixture.serviceCalls, [
|
|
237
|
-
`${fixture.databaseA.id}:query:SELECT 1:Stored API Query:api`,
|
|
238
|
-
]);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
test("query API rejects read-only raw SQL execution", async (t) => {
|
|
242
|
-
const fixture = await startApi(t);
|
|
243
|
-
const created = fixture.tokenService.createToken(fixture.databaseA.id, "Automation");
|
|
244
|
-
const response = await fetch(`${fixture.baseUrl}/query`, {
|
|
245
|
-
method: "POST",
|
|
246
|
-
headers: {
|
|
247
|
-
Authorization: `Bearer ${created.token}`,
|
|
248
|
-
"Content-Type": "application/json",
|
|
249
|
-
},
|
|
250
|
-
body: JSON.stringify({
|
|
251
|
-
databaseId: fixture.databaseA.id,
|
|
252
|
-
sql: "READONLY",
|
|
253
|
-
}),
|
|
254
|
-
});
|
|
255
|
-
const payload = await response.json();
|
|
256
|
-
|
|
257
|
-
assert.equal(response.status, 403);
|
|
258
|
-
assert.equal(payload.error.code, "SQLITE_READONLY");
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
test("saved query API records executions as api", async (t) => {
|
|
262
|
-
const fixture = await startApi(t);
|
|
263
|
-
const created = fixture.tokenService.createToken(fixture.databaseA.id, "Automation");
|
|
264
|
-
const response = await fetch(
|
|
265
|
-
`${fixture.baseUrl}/databases/${fixture.databaseA.id}/queries/Hype-Reversal/execute`,
|
|
266
|
-
{
|
|
267
|
-
method: "POST",
|
|
268
|
-
headers: { Authorization: `Bearer ${created.token}` },
|
|
269
|
-
}
|
|
270
|
-
);
|
|
271
|
-
const payload = await response.json();
|
|
272
|
-
|
|
273
|
-
assert.equal(response.status, 200);
|
|
274
|
-
assert.equal(payload.data.historyId, 7);
|
|
275
|
-
assert.equal(payload.metadata.query.title, "Hype-Reversal");
|
|
276
|
-
assert.deepEqual(fixture.serviceCalls, [
|
|
277
|
-
`${fixture.databaseA.id}:saved:Hype-Reversal:api`,
|
|
278
|
-
]);
|
|
279
|
-
});
|
|
@@ -1,140 +0,0 @@
|
|
|
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 { BackupService } = require("../server/services/sqlite/backupService");
|
|
9
|
-
const { ConnectionManager } = require("../server/services/sqlite/connectionManager");
|
|
10
|
-
const { AppStateStore } = require("../server/services/storage/appStateStore");
|
|
11
|
-
|
|
12
|
-
function createFixture(t) {
|
|
13
|
-
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-hub-backups-"));
|
|
14
|
-
const databasePath = path.join(directory, "source.sqlite");
|
|
15
|
-
const db = new Database(databasePath);
|
|
16
|
-
|
|
17
|
-
db.exec(`
|
|
18
|
-
CREATE TABLE companies (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
|
|
19
|
-
INSERT INTO companies (name) VALUES ('Acme'), ('Globex');
|
|
20
|
-
`);
|
|
21
|
-
db.close();
|
|
22
|
-
|
|
23
|
-
const store = new AppStateStore(path.join(directory, "state.db"));
|
|
24
|
-
const connectionManager = new ConnectionManager({ appStateStore: store });
|
|
25
|
-
const connection = connectionManager.openConnection({
|
|
26
|
-
filePath: databasePath,
|
|
27
|
-
label: "Source",
|
|
28
|
-
id: "conn_source",
|
|
29
|
-
makeActive: true,
|
|
30
|
-
});
|
|
31
|
-
const backupService = new BackupService({
|
|
32
|
-
appStateStore: store,
|
|
33
|
-
connectionManager,
|
|
34
|
-
backupRootDirectory: path.join(directory, "SQLite Hub", "backups"),
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
t.after(() => {
|
|
38
|
-
connectionManager.closeCurrent();
|
|
39
|
-
store.db.close();
|
|
40
|
-
fs.rmSync(directory, { recursive: true, force: true });
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
return {
|
|
44
|
-
backupService,
|
|
45
|
-
connection,
|
|
46
|
-
connectionManager,
|
|
47
|
-
directory,
|
|
48
|
-
store,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
test("backups table is created with recent_connections foreign key and checks", (t) => {
|
|
53
|
-
const { store } = createFixture(t);
|
|
54
|
-
const tables = new Set(
|
|
55
|
-
store.db.prepare("SELECT name FROM sqlite_schema WHERE type = 'table'").all().map(row => row.name)
|
|
56
|
-
);
|
|
57
|
-
const foreignKeys = store.db.prepare("PRAGMA foreign_key_list(backups)").all();
|
|
58
|
-
|
|
59
|
-
assert.equal(tables.has("backups"), true);
|
|
60
|
-
assert.equal(foreignKeys[0].table, "recent_connections");
|
|
61
|
-
assert.equal(foreignKeys[0].from, "connectionId");
|
|
62
|
-
assert.equal(foreignKeys[0].on_delete, "SET NULL");
|
|
63
|
-
|
|
64
|
-
assert.throws(
|
|
65
|
-
() =>
|
|
66
|
-
store.createBackupRecord({
|
|
67
|
-
id: "invalid",
|
|
68
|
-
connectionId: "conn_source",
|
|
69
|
-
name: "Invalid",
|
|
70
|
-
path: path.join(os.tmpdir(), "invalid.sqlite"),
|
|
71
|
-
status: "unknown",
|
|
72
|
-
type: "manual",
|
|
73
|
-
sourcePath: path.join(os.tmpdir(), "source.sqlite"),
|
|
74
|
-
createdAt: new Date().toISOString(),
|
|
75
|
-
}),
|
|
76
|
-
/CHECK constraint failed/
|
|
77
|
-
);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("manual backup creates file, metadata, manifest, and survives connection removal", async (t) => {
|
|
81
|
-
const { backupService, connection, store } = createFixture(t);
|
|
82
|
-
|
|
83
|
-
const backup = await backupService.createActiveBackup({
|
|
84
|
-
name: "Before migration",
|
|
85
|
-
notes: "Schema update",
|
|
86
|
-
});
|
|
87
|
-
const manifestPath = path.join(
|
|
88
|
-
backupService.backupRootDirectory,
|
|
89
|
-
connection.id,
|
|
90
|
-
"manifest.json"
|
|
91
|
-
);
|
|
92
|
-
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
93
|
-
const stored = store.getBackup(backup.id);
|
|
94
|
-
|
|
95
|
-
assert.equal(backup.status, "verified");
|
|
96
|
-
assert.equal(backup.connectionId, connection.id);
|
|
97
|
-
assert.equal(backup.notes, "Schema update");
|
|
98
|
-
assert.equal(fs.existsSync(backup.path), true);
|
|
99
|
-
assert.match(path.relative(backupService.backupRootDirectory, backup.path), /^conn_source\//);
|
|
100
|
-
assert.equal(stored.checksumSha256.length, 64);
|
|
101
|
-
assert.equal(stored.tableCount, 1);
|
|
102
|
-
assert.equal(stored.rowCount, 2);
|
|
103
|
-
assert.equal(manifest.databaseId, connection.id);
|
|
104
|
-
assert.equal(manifest.backups[0].id, backup.id);
|
|
105
|
-
assert.equal(manifest.backups[0].status, "verified");
|
|
106
|
-
|
|
107
|
-
store.removeRecentConnection(connection.id);
|
|
108
|
-
assert.equal(store.getBackup(backup.id).connectionId, null);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("backup notes can be updated after creation and manifest stays in sync", async (t) => {
|
|
112
|
-
const { backupService, connection, store } = createFixture(t);
|
|
113
|
-
|
|
114
|
-
const backup = await backupService.createActiveBackup({
|
|
115
|
-
name: "Before migration",
|
|
116
|
-
notes: "Initial note",
|
|
117
|
-
});
|
|
118
|
-
const updated = backupService.updateBackupNotes(backup.id, "Reviewed after restore test");
|
|
119
|
-
const manifestPath = path.join(
|
|
120
|
-
backupService.backupRootDirectory,
|
|
121
|
-
connection.id,
|
|
122
|
-
"manifest.json"
|
|
123
|
-
);
|
|
124
|
-
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
125
|
-
|
|
126
|
-
assert.equal(updated.notes, "Reviewed after restore test");
|
|
127
|
-
assert.equal(store.getBackup(backup.id).notes, "Reviewed after restore test");
|
|
128
|
-
assert.equal(manifest.backups[0].notes, "Reviewed after restore test");
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test("delete backup removes file, record, and manifest entry", async (t) => {
|
|
132
|
-
const { backupService, store } = createFixture(t);
|
|
133
|
-
const backup = await backupService.createActiveBackup({ name: "Manual backup" });
|
|
134
|
-
const backupPath = backup.path;
|
|
135
|
-
|
|
136
|
-
backupService.deleteBackup(backup.id);
|
|
137
|
-
|
|
138
|
-
assert.equal(fs.existsSync(backupPath), false);
|
|
139
|
-
assert.equal(store.getBackup(backup.id), null);
|
|
140
|
-
});
|
|
@@ -1,70 +0,0 @@
|
|
|
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
|
-
async function loadModule() {
|
|
7
|
-
return import(pathToFileURL(path.resolve(__dirname, "../frontend/js/views/backups.js")).href);
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function createState(overrides = {}) {
|
|
11
|
-
return {
|
|
12
|
-
connections: {
|
|
13
|
-
active: { id: "db-one", label: "Customers", readOnly: false },
|
|
14
|
-
},
|
|
15
|
-
backups: {
|
|
16
|
-
items: [],
|
|
17
|
-
loading: false,
|
|
18
|
-
operationLoading: false,
|
|
19
|
-
error: null,
|
|
20
|
-
},
|
|
21
|
-
...overrides,
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
test("backup manager renders empty state and create action", async () => {
|
|
26
|
-
const { renderBackupsView } = await loadModule();
|
|
27
|
-
const rendered = renderBackupsView(createState()).main;
|
|
28
|
-
|
|
29
|
-
assert.match(rendered, /No backups yet/);
|
|
30
|
-
assert.match(rendered, /data-action="open-create-backup-modal"/);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("backup manager renders backup rows with status and actions", async () => {
|
|
34
|
-
const { renderBackupsView } = await loadModule();
|
|
35
|
-
const rendered = renderBackupsView(
|
|
36
|
-
createState({
|
|
37
|
-
backups: {
|
|
38
|
-
items: [
|
|
39
|
-
{
|
|
40
|
-
id: "backup-one",
|
|
41
|
-
name: "Before migration",
|
|
42
|
-
notes: "Schema update",
|
|
43
|
-
sizeBytes: 1024,
|
|
44
|
-
status: "verified",
|
|
45
|
-
sqliteHubVersion: "1.1.2",
|
|
46
|
-
sqliteVersion: "3.50.0",
|
|
47
|
-
fileExists: true,
|
|
48
|
-
fileName: "backup.sqlite",
|
|
49
|
-
path: "/tmp/backup.sqlite",
|
|
50
|
-
createdAt: "2026-06-21T11:42:18.000Z",
|
|
51
|
-
},
|
|
52
|
-
],
|
|
53
|
-
loading: false,
|
|
54
|
-
operationLoading: false,
|
|
55
|
-
error: null,
|
|
56
|
-
},
|
|
57
|
-
})
|
|
58
|
-
).main;
|
|
59
|
-
|
|
60
|
-
assert.match(rendered, /Before migration/);
|
|
61
|
-
assert.match(rendered, /Verified/);
|
|
62
|
-
assert.match(rendered, /SQLite Hub/);
|
|
63
|
-
assert.match(rendered, /v1\.1\.2/);
|
|
64
|
-
assert.match(rendered, /SQLite/);
|
|
65
|
-
assert.match(rendered, /v3\.50\.0/);
|
|
66
|
-
assert.match(rendered, /data-action="open-edit-backup-notes-modal"/);
|
|
67
|
-
assert.match(rendered, /data-action="open-restore-backup-modal"/);
|
|
68
|
-
assert.match(rendered, /data-action="download-backup"/);
|
|
69
|
-
assert.match(rendered, /data-action="open-delete-backup-modal"/);
|
|
70
|
-
});
|
|
@@ -1,60 +0,0 @@
|
|
|
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
|
-
const CHARTS_HEIGHT_STORAGE_KEY = "sqlite_hub_charts_height_preset";
|
|
7
|
-
|
|
8
|
-
function createMockStorage(initialValues = {}) {
|
|
9
|
-
const entries = new Map(Object.entries(initialValues));
|
|
10
|
-
|
|
11
|
-
return {
|
|
12
|
-
getItem(key) {
|
|
13
|
-
return entries.has(key) ? entries.get(key) : null;
|
|
14
|
-
},
|
|
15
|
-
setItem(key, value) {
|
|
16
|
-
entries.set(key, String(value));
|
|
17
|
-
},
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async function importStoreWithMockStorage(storage) {
|
|
22
|
-
const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
|
23
|
-
|
|
24
|
-
Object.defineProperty(globalThis, "localStorage", {
|
|
25
|
-
configurable: true,
|
|
26
|
-
value: storage,
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
const moduleUrl = pathToFileURL(path.resolve(__dirname, "../frontend/js/store.js")).href;
|
|
30
|
-
const store = await import(`${moduleUrl}?charts-height=${Date.now()}`);
|
|
31
|
-
|
|
32
|
-
return {
|
|
33
|
-
restore() {
|
|
34
|
-
if (originalDescriptor) {
|
|
35
|
-
Object.defineProperty(globalThis, "localStorage", originalDescriptor);
|
|
36
|
-
} else {
|
|
37
|
-
delete globalThis.localStorage;
|
|
38
|
-
}
|
|
39
|
-
},
|
|
40
|
-
store,
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
test("charts height preset is read from and written to localStorage", async () => {
|
|
45
|
-
const storage = createMockStorage({
|
|
46
|
-
[CHARTS_HEIGHT_STORAGE_KEY]: "large",
|
|
47
|
-
});
|
|
48
|
-
const { restore, store } = await importStoreWithMockStorage(storage);
|
|
49
|
-
|
|
50
|
-
try {
|
|
51
|
-
assert.equal(store.getState().charts.chartHeightPreset, "large");
|
|
52
|
-
|
|
53
|
-
store.setChartsHeightPreset("small");
|
|
54
|
-
|
|
55
|
-
assert.equal(store.getState().charts.chartHeightPreset, "small");
|
|
56
|
-
assert.equal(storage.getItem(CHARTS_HEIGHT_STORAGE_KEY), "small");
|
|
57
|
-
} finally {
|
|
58
|
-
restore();
|
|
59
|
-
}
|
|
60
|
-
});
|