sqlite-hub 1.1.1 → 1.2.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 +17 -196
- package/bin/sqlite-hub.js +7 -2
- package/frontend/js/api.js +60 -0
- package/frontend/js/app.js +201 -5
- package/frontend/js/components/dropdownButton.js +92 -0
- package/frontend/js/components/modal.js +991 -876
- package/frontend/js/components/queryEditor.js +24 -15
- package/frontend/js/components/queryHistoryDetail.js +20 -1
- package/frontend/js/components/queryHistoryHeader.js +3 -2
- package/frontend/js/components/queryResults.js +1 -1
- package/frontend/js/components/rowEditorPanel.js +53 -13
- package/frontend/js/components/sidebar.js +1 -0
- package/frontend/js/components/topNav.js +1 -4
- package/frontend/js/components/workspaceOpenDropdown.js +52 -0
- package/frontend/js/router.js +2 -0
- package/frontend/js/store.js +450 -38
- package/frontend/js/utils/emailPreview.js +28 -0
- package/frontend/js/utils/markdownDocuments.js +17 -1
- package/frontend/js/utils/riskySql.js +165 -0
- package/frontend/js/views/backups.js +204 -0
- package/frontend/js/views/charts.js +1 -1
- package/frontend/js/views/data.js +42 -16
- package/frontend/js/views/documents.js +184 -154
- package/frontend/js/views/settings.js +1 -0
- package/frontend/js/views/structure.js +47 -33
- package/frontend/js/views/tableDesigner.js +23 -0
- package/frontend/styles/components.css +133 -0
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/tokens.css +1 -0
- package/frontend/styles/views.css +33 -21
- package/package.json +2 -1
- package/server/routes/backups.js +120 -0
- package/server/routes/connections.js +26 -3
- package/server/routes/externalApi.js +6 -2
- package/server/server.js +3 -1
- package/server/services/databaseCommandService.js +4 -3
- package/server/services/sqlite/backupService.js +497 -66
- package/server/services/sqlite/connectionManager.js +2 -2
- package/server/services/sqlite/importService.js +25 -0
- package/server/services/sqlite/sqlExecutor.js +2 -0
- package/server/services/storage/appStateStore.js +379 -88
- package/tests/api-token-auth.test.js +45 -2
- package/tests/backup-manager.test.js +140 -0
- package/tests/backups-view.test.js +70 -0
- package/tests/charts-height-preset-storage.test.js +60 -0
- package/tests/charts-route-state.test.js +144 -0
- package/tests/cli-service-delegation.test.js +97 -0
- package/tests/connection-removal.test.js +52 -0
- package/tests/database-command-service.test.js +37 -2
- package/tests/documents-view.test.js +132 -0
- package/tests/dropdown-button.test.js +101 -0
- package/tests/email-preview.test.js +89 -0
- package/tests/markdown-documents.test.js +24 -0
- package/tests/query-editor.test.js +28 -0
- package/tests/query-history-detail.test.js +37 -0
- package/tests/query-history-header.test.js +30 -0
- package/tests/risky-sql.test.js +30 -0
- package/tests/row-editor-null-values.test.js +53 -0
- package/tests/settings-view.test.js +1 -0
- package/tests/structure-view.test.js +60 -0
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
function jsonResponse(payload) {
|
|
7
|
+
return new Response(JSON.stringify({ success: true, ...payload }), {
|
|
8
|
+
headers: { "content-type": "application/json" },
|
|
9
|
+
status: 200,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function createChartsFetchMock() {
|
|
14
|
+
const activeConnection = {
|
|
15
|
+
id: "db-one",
|
|
16
|
+
label: "Database One",
|
|
17
|
+
readOnly: false,
|
|
18
|
+
};
|
|
19
|
+
const chartableQuery = {
|
|
20
|
+
id: 10,
|
|
21
|
+
displayTitle: "Revenue Chart",
|
|
22
|
+
previewSql: "select revenue from metrics",
|
|
23
|
+
rawSql: "select revenue from metrics",
|
|
24
|
+
queryType: "select",
|
|
25
|
+
isSaved: false,
|
|
26
|
+
isDestructive: false,
|
|
27
|
+
chartTypes: ["bar"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return async function fetchMock(input, options = {}) {
|
|
31
|
+
const url = typeof input === "string" ? input : input.url;
|
|
32
|
+
const method = String(options.method ?? "GET").toUpperCase();
|
|
33
|
+
|
|
34
|
+
if (method === "GET" && url === "/api/connections/recent") {
|
|
35
|
+
return jsonResponse({ data: [activeConnection] });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (method === "GET" && url === "/api/connections/active") {
|
|
39
|
+
return jsonResponse({ data: activeConnection });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (method === "GET" && url === "/api/settings") {
|
|
43
|
+
return jsonResponse({
|
|
44
|
+
data: {},
|
|
45
|
+
metadata: {
|
|
46
|
+
activeDatabase: activeConnection,
|
|
47
|
+
apiTokens: [],
|
|
48
|
+
appVersion: "1.1.2",
|
|
49
|
+
sqliteVersion: "3.50.0",
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (method === "GET" && url.startsWith("/api/sql/history")) {
|
|
55
|
+
return jsonResponse({ data: { items: [], total: 0, hasMore: false } });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (method === "GET" && url === "/api/db/overview") {
|
|
59
|
+
return jsonResponse({ data: { tables: [] } });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (method === "GET" && url === "/api/charts/query-history") {
|
|
63
|
+
return jsonResponse({ data: [chartableQuery] });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (method === "GET" && url === "/api/charts/query-history/10") {
|
|
67
|
+
return jsonResponse({
|
|
68
|
+
data: {
|
|
69
|
+
item: chartableQuery,
|
|
70
|
+
charts: [],
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (method === "POST" && url === "/api/charts/query-history/10/execute") {
|
|
76
|
+
return jsonResponse({
|
|
77
|
+
data: {
|
|
78
|
+
columns: ["revenue"],
|
|
79
|
+
rows: [{ revenue: 100 }],
|
|
80
|
+
sql: chartableQuery.rawSql,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return new Response(
|
|
86
|
+
JSON.stringify({
|
|
87
|
+
success: false,
|
|
88
|
+
error: { code: "UNEXPECTED_REQUEST", message: `${method} ${url}` },
|
|
89
|
+
}),
|
|
90
|
+
{
|
|
91
|
+
headers: { "content-type": "application/json" },
|
|
92
|
+
status: 500,
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function importStoreWithFetch(fetchMock) {
|
|
99
|
+
const originalFetch = globalThis.fetch;
|
|
100
|
+
|
|
101
|
+
globalThis.fetch = fetchMock;
|
|
102
|
+
|
|
103
|
+
const moduleUrl = pathToFileURL(path.resolve(__dirname, "../frontend/js/store.js")).href;
|
|
104
|
+
const store = await import(`${moduleUrl}?charts-route-state=${Date.now()}`);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
restore() {
|
|
108
|
+
globalThis.fetch = originalFetch;
|
|
109
|
+
},
|
|
110
|
+
store,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
test("charts route preserves the selected chart when returning from another menu", async () => {
|
|
115
|
+
const { restore, store } = await importStoreWithFetch(createChartsFetchMock());
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await store.initializeApp();
|
|
119
|
+
await store.setRoute({
|
|
120
|
+
name: "charts",
|
|
121
|
+
params: { historyId: "10" },
|
|
122
|
+
path: "/charts/10",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
assert.equal(store.getState().charts.selectedHistoryId, 10);
|
|
126
|
+
assert.equal(store.getState().charts.detail?.item?.id, 10);
|
|
127
|
+
|
|
128
|
+
await store.setRoute({
|
|
129
|
+
name: "overview",
|
|
130
|
+
params: {},
|
|
131
|
+
path: "/overview",
|
|
132
|
+
});
|
|
133
|
+
await store.setRoute({
|
|
134
|
+
name: "charts",
|
|
135
|
+
params: { historyId: null },
|
|
136
|
+
path: "/charts",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
assert.equal(store.getState().charts.selectedHistoryId, 10);
|
|
140
|
+
assert.equal(store.getState().charts.detail?.item?.id, 10);
|
|
141
|
+
} finally {
|
|
142
|
+
restore();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
@@ -41,3 +41,100 @@ test("CLI delegates database operations to the shared command service", async ()
|
|
|
41
41
|
]);
|
|
42
42
|
assert.match(output.join("\n"), /companies/);
|
|
43
43
|
});
|
|
44
|
+
|
|
45
|
+
test("CLI marks raw query executions as cli", async () => {
|
|
46
|
+
const calls = [];
|
|
47
|
+
const connection = {
|
|
48
|
+
id: "db-one",
|
|
49
|
+
label: "Database One",
|
|
50
|
+
};
|
|
51
|
+
const databaseService = {
|
|
52
|
+
getDatabase(reference) {
|
|
53
|
+
calls.push(["getDatabase", reference]);
|
|
54
|
+
return connection;
|
|
55
|
+
},
|
|
56
|
+
listDatabases() {
|
|
57
|
+
calls.push(["listDatabases"]);
|
|
58
|
+
return [connection];
|
|
59
|
+
},
|
|
60
|
+
executeRawQuery(reference, sql, options = {}) {
|
|
61
|
+
calls.push(["executeRawQuery", reference, sql, options]);
|
|
62
|
+
return {
|
|
63
|
+
result: {
|
|
64
|
+
statementCount: 1,
|
|
65
|
+
timingMs: 1,
|
|
66
|
+
statements: [],
|
|
67
|
+
historyId: 7,
|
|
68
|
+
},
|
|
69
|
+
storedQuery: null,
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
const output = [];
|
|
74
|
+
const originalLog = console.log;
|
|
75
|
+
|
|
76
|
+
console.log = (...values) => output.push(values.join(" "));
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
await main(["--database:Database One", "--query:SELECT 1"], { databaseService });
|
|
80
|
+
} finally {
|
|
81
|
+
console.log = originalLog;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
assert.deepEqual(calls, [
|
|
85
|
+
["listDatabases"],
|
|
86
|
+
["getDatabase", "Database One"],
|
|
87
|
+
["executeRawQuery", "db-one", "SELECT 1", { storeName: null, executedBy: "cli" }],
|
|
88
|
+
]);
|
|
89
|
+
assert.match(output.join("\n"), /History ID: 7/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("CLI marks saved query executions as cli", async () => {
|
|
93
|
+
const calls = [];
|
|
94
|
+
const connection = {
|
|
95
|
+
id: "db-one",
|
|
96
|
+
label: "Database One",
|
|
97
|
+
};
|
|
98
|
+
const databaseService = {
|
|
99
|
+
getDatabase(reference) {
|
|
100
|
+
calls.push(["getDatabase", reference]);
|
|
101
|
+
return connection;
|
|
102
|
+
},
|
|
103
|
+
listDatabases() {
|
|
104
|
+
calls.push(["listDatabases"]);
|
|
105
|
+
return [connection];
|
|
106
|
+
},
|
|
107
|
+
executeSavedQuery(reference, queryName, options = {}) {
|
|
108
|
+
calls.push(["executeSavedQuery", reference, queryName, options]);
|
|
109
|
+
return {
|
|
110
|
+
query: {
|
|
111
|
+
title: queryName,
|
|
112
|
+
rawSql: "SELECT 1",
|
|
113
|
+
},
|
|
114
|
+
result: {
|
|
115
|
+
statementCount: 1,
|
|
116
|
+
timingMs: 1,
|
|
117
|
+
statements: [],
|
|
118
|
+
historyId: 8,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
const output = [];
|
|
124
|
+
const originalLog = console.log;
|
|
125
|
+
|
|
126
|
+
console.log = (...values) => output.push(values.join(" "));
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
await main(["--database:Database One", "--execute:Hype-Reversal"], { databaseService });
|
|
130
|
+
} finally {
|
|
131
|
+
console.log = originalLog;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
assert.deepEqual(calls, [
|
|
135
|
+
["listDatabases"],
|
|
136
|
+
["getDatabase", "Database One"],
|
|
137
|
+
["executeSavedQuery", "db-one", "Hype-Reversal", { executedBy: "cli" }],
|
|
138
|
+
]);
|
|
139
|
+
assert.match(output.join("\n"), /Executing: Hype-Reversal/);
|
|
140
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
|
|
7
|
+
const { ConnectionManager } = require("../server/services/sqlite/connectionManager");
|
|
8
|
+
const { AppStateStore } = require("../server/services/storage/appStateStore");
|
|
9
|
+
|
|
10
|
+
function createStore(t) {
|
|
11
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "sqlite-hub-connection-remove-"));
|
|
12
|
+
const store = new AppStateStore(path.join(directory, "state.db"));
|
|
13
|
+
|
|
14
|
+
t.after(() => {
|
|
15
|
+
store.db.close();
|
|
16
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return { directory, store };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("removing a recent connection does not require legacy sql_history", (t) => {
|
|
23
|
+
const { directory, store } = createStore(t);
|
|
24
|
+
const firstConnection = {
|
|
25
|
+
id: "db-one",
|
|
26
|
+
label: "One",
|
|
27
|
+
path: path.join(directory, "one.db"),
|
|
28
|
+
lastOpenedAt: "2026-06-21T10:00:00.000Z",
|
|
29
|
+
lastModifiedAt: null,
|
|
30
|
+
sizeBytes: 0,
|
|
31
|
+
readOnly: false,
|
|
32
|
+
logoPath: null,
|
|
33
|
+
};
|
|
34
|
+
const secondConnection = {
|
|
35
|
+
...firstConnection,
|
|
36
|
+
id: "db-two",
|
|
37
|
+
label: "Two",
|
|
38
|
+
path: path.join(directory, "two.db"),
|
|
39
|
+
};
|
|
40
|
+
const manager = new ConnectionManager({ appStateStore: store });
|
|
41
|
+
|
|
42
|
+
store.upsertRecentConnection(firstConnection);
|
|
43
|
+
store.upsertRecentConnection(secondConnection, { makeActive: false });
|
|
44
|
+
store.db.exec("DROP TABLE sql_history");
|
|
45
|
+
|
|
46
|
+
const remaining = manager.removeRecentConnection(firstConnection.id);
|
|
47
|
+
|
|
48
|
+
assert.deepEqual(
|
|
49
|
+
remaining.map((connection) => connection.id),
|
|
50
|
+
["db-two"]
|
|
51
|
+
);
|
|
52
|
+
});
|
|
@@ -59,6 +59,29 @@ function createFixture(t, options = {}) {
|
|
|
59
59
|
new Date().toISOString(),
|
|
60
60
|
new Date().toISOString()
|
|
61
61
|
);
|
|
62
|
+
store.db
|
|
63
|
+
.prepare(
|
|
64
|
+
`
|
|
65
|
+
INSERT INTO query_history (
|
|
66
|
+
database_key,
|
|
67
|
+
normalized_sql,
|
|
68
|
+
raw_sql,
|
|
69
|
+
query_type,
|
|
70
|
+
tables_detected,
|
|
71
|
+
is_saved,
|
|
72
|
+
first_executed_at,
|
|
73
|
+
last_used_at
|
|
74
|
+
)
|
|
75
|
+
VALUES (?, ?, ?, 'other', '[]', 0, ?, ?)
|
|
76
|
+
`
|
|
77
|
+
)
|
|
78
|
+
.run(
|
|
79
|
+
connection.id,
|
|
80
|
+
"company list",
|
|
81
|
+
"Company List",
|
|
82
|
+
new Date().toISOString(),
|
|
83
|
+
new Date().toISOString()
|
|
84
|
+
);
|
|
62
85
|
store.createDatabaseDocument(connection.id, {
|
|
63
86
|
filename: "Readme.md",
|
|
64
87
|
content: "# Sample\n",
|
|
@@ -77,7 +100,7 @@ function createFixture(t, options = {}) {
|
|
|
77
100
|
}
|
|
78
101
|
|
|
79
102
|
test("database command service provides shared CLI and API operations", (t) => {
|
|
80
|
-
const { connection, service } = createFixture(t);
|
|
103
|
+
const { connection, service, store } = createFixture(t);
|
|
81
104
|
|
|
82
105
|
assert.equal(service.getDatabase("sample").id, connection.id);
|
|
83
106
|
assert.deepEqual(service.listTables(connection.id), [{ name: "companies", columnCount: 2 }]);
|
|
@@ -89,10 +112,18 @@ test("database command service provides shared CLI and API operations", (t) => {
|
|
|
89
112
|
|
|
90
113
|
const queries = service.listSavedQueries(connection.id);
|
|
91
114
|
assert.equal(queries.total, 1);
|
|
92
|
-
|
|
115
|
+
const savedQuery = service.getSavedQuery(connection.id, "Company List");
|
|
116
|
+
assert.equal(savedQuery.notes, "Used by CLI and API");
|
|
117
|
+
assert.equal(savedQuery.rawSql, "SELECT id, name FROM companies ORDER BY id");
|
|
93
118
|
|
|
94
119
|
const execution = service.executeSavedQuery(connection.id, "Company List");
|
|
95
120
|
assert.equal(execution.result.statements[0].rowCount, 2);
|
|
121
|
+
assert.equal(execution.result.historyId, savedQuery.id);
|
|
122
|
+
|
|
123
|
+
const savedRun = store.db
|
|
124
|
+
.prepare("SELECT executed_by, status FROM query_runs WHERE history_id = ? ORDER BY id DESC LIMIT 1")
|
|
125
|
+
.get(savedQuery.id);
|
|
126
|
+
assert.deepEqual(savedRun, { executed_by: "user", status: "success" });
|
|
96
127
|
|
|
97
128
|
const exported = service.exportSavedQuery(connection.id, "Company List", "csv");
|
|
98
129
|
assert.equal(exported.result.rowCount, 2);
|
|
@@ -118,6 +149,9 @@ test("raw query execution writes SQL Editor query history", (t) => {
|
|
|
118
149
|
const historyRow = store.db
|
|
119
150
|
.prepare("SELECT raw_sql, query_type, title, is_saved FROM query_history WHERE id = ?")
|
|
120
151
|
.get(result.historyId);
|
|
152
|
+
const runRow = store.db
|
|
153
|
+
.prepare("SELECT executed_by FROM query_runs WHERE history_id = ? ORDER BY id DESC LIMIT 1")
|
|
154
|
+
.get(result.historyId);
|
|
121
155
|
|
|
122
156
|
assert.equal(result.affectedRowCount, 1);
|
|
123
157
|
assert.equal(afterCount, beforeCount + 1);
|
|
@@ -125,6 +159,7 @@ test("raw query execution writes SQL Editor query history", (t) => {
|
|
|
125
159
|
assert.equal(historyRow.query_type, "insert");
|
|
126
160
|
assert.equal(historyRow.title, "Add Initech");
|
|
127
161
|
assert.equal(historyRow.is_saved, 1);
|
|
162
|
+
assert.equal(runRow.executed_by, "user");
|
|
128
163
|
assert.equal(storedQuery.title, "Add Initech");
|
|
129
164
|
assert.equal(storedQuery.isSaved, true);
|
|
130
165
|
});
|