sqlite-hub 1.1.1 → 1.1.2
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 +110 -0
- 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/rowEditorPanel.js +1 -0
- package/frontend/js/components/sidebar.js +1 -0
- package/frontend/js/router.js +2 -0
- package/frontend/js/store.js +383 -37
- package/frontend/js/utils/riskySql.js +165 -0
- package/frontend/js/views/backups.js +176 -0
- package/frontend/js/views/charts.js +1 -1
- package/frontend/js/views/documents.js +184 -154
- package/frontend/js/views/structure.js +26 -24
- package/frontend/styles/components.css +128 -0
- package/frontend/styles/tailwind.generated.css +1 -1
- 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 +64 -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 +75 -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 +24 -0
- package/tests/structure-view.test.js +56 -0
|
@@ -44,6 +44,7 @@ const STATE_DATABASE_FILENAME = "sqlite-hub-state.db";
|
|
|
44
44
|
const MAX_CONNECTION_LOGO_SIZE_BYTES = 5 * 1024 * 1024;
|
|
45
45
|
const MAX_DOCUMENT_CONTENT_BYTES = 5 * 1024 * 1024;
|
|
46
46
|
const MAX_DOCUMENT_FILENAME_LENGTH = 160;
|
|
47
|
+
const QUERY_EXECUTION_SOURCES = new Set(["api", "cli", "user", "mcp"]);
|
|
47
48
|
const CONNECTION_LOGO_EXTENSION_BY_MIME_TYPE = {
|
|
48
49
|
"image/jpeg": "jpg",
|
|
49
50
|
"image/png": "png",
|
|
@@ -125,6 +126,24 @@ function buildDocumentTitleFromFilename(filename) {
|
|
|
125
126
|
return title || "Untitled";
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
function normalizeQueryExecutionSource(value, fallback = "user") {
|
|
130
|
+
const normalized = String(value ?? fallback)
|
|
131
|
+
.trim()
|
|
132
|
+
.toLowerCase();
|
|
133
|
+
|
|
134
|
+
return QUERY_EXECUTION_SOURCES.has(normalized) ? normalized : fallback;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function requireQueryExecutionSource(value) {
|
|
138
|
+
const normalized = normalizeQueryExecutionSource(value, null);
|
|
139
|
+
|
|
140
|
+
if (!normalized) {
|
|
141
|
+
throw new ValidationError(`Unsupported query execution source: ${value}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return normalized;
|
|
145
|
+
}
|
|
146
|
+
|
|
128
147
|
function normalizeDocumentTitle(value, filename) {
|
|
129
148
|
const title = String(value ?? "").trim();
|
|
130
149
|
|
|
@@ -152,7 +171,6 @@ const CONNECTION_LOGO_EXTENSION_BY_FILE_EXTENSION = {
|
|
|
152
171
|
".png": "png",
|
|
153
172
|
".webp": "webp",
|
|
154
173
|
};
|
|
155
|
-
const QUERY_HISTORY_MIGRATION_KEY = "queryHistoryV1Migrated";
|
|
156
174
|
const MEDIA_TAGGING_CONFIG_FIELDS = [
|
|
157
175
|
{
|
|
158
176
|
column: "tag_table",
|
|
@@ -301,8 +319,6 @@ class AppStateStore {
|
|
|
301
319
|
if (!importedLegacyDatabase && this.shouldImportLegacyState()) {
|
|
302
320
|
this.tryImportLegacyState();
|
|
303
321
|
}
|
|
304
|
-
|
|
305
|
-
this.migrateLegacySqlHistory();
|
|
306
322
|
}
|
|
307
323
|
|
|
308
324
|
configureDatabase() {
|
|
@@ -379,6 +395,7 @@ class AppStateStore {
|
|
|
379
395
|
status TEXT NOT NULL CHECK(status IN ('success', 'error')),
|
|
380
396
|
error_message TEXT,
|
|
381
397
|
affected_rows INTEGER,
|
|
398
|
+
executed_by TEXT NOT NULL DEFAULT 'user' CHECK(executed_by IN ('api', 'cli', 'user', 'mcp')),
|
|
382
399
|
FOREIGN KEY (history_id) REFERENCES query_history(id) ON DELETE CASCADE
|
|
383
400
|
);
|
|
384
401
|
|
|
@@ -470,6 +487,52 @@ class AppStateStore {
|
|
|
470
487
|
|
|
471
488
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_database_created
|
|
472
489
|
ON api_tokens(database_key, created_at DESC, id ASC);
|
|
490
|
+
|
|
491
|
+
CREATE TABLE IF NOT EXISTS backups (
|
|
492
|
+
id TEXT PRIMARY KEY,
|
|
493
|
+
connectionId TEXT,
|
|
494
|
+
name TEXT NOT NULL,
|
|
495
|
+
notes TEXT,
|
|
496
|
+
path TEXT NOT NULL,
|
|
497
|
+
sizeBytes INTEGER NOT NULL DEFAULT 0,
|
|
498
|
+
status TEXT NOT NULL DEFAULT 'creating'
|
|
499
|
+
CHECK(status IN ('creating', 'verifying', 'verified', 'failed', 'restoring')),
|
|
500
|
+
type TEXT NOT NULL DEFAULT 'manual'
|
|
501
|
+
CHECK(type IN (
|
|
502
|
+
'manual',
|
|
503
|
+
'automatic',
|
|
504
|
+
'pre_restore',
|
|
505
|
+
'pre_migration',
|
|
506
|
+
'pre_import',
|
|
507
|
+
'pre_schema_change'
|
|
508
|
+
)),
|
|
509
|
+
sourcePath TEXT NOT NULL,
|
|
510
|
+
sourceLabel TEXT,
|
|
511
|
+
sqliteHubVersion TEXT,
|
|
512
|
+
sqliteVersion TEXT,
|
|
513
|
+
journalMode TEXT,
|
|
514
|
+
schemaVersion INTEGER,
|
|
515
|
+
tableCount INTEGER,
|
|
516
|
+
rowCount INTEGER,
|
|
517
|
+
checksumSha256 TEXT,
|
|
518
|
+
createdAt TEXT NOT NULL,
|
|
519
|
+
verifiedAt TEXT,
|
|
520
|
+
lastRestoredAt TEXT,
|
|
521
|
+
errorMessage TEXT,
|
|
522
|
+
FOREIGN KEY (connectionId)
|
|
523
|
+
REFERENCES recent_connections(id)
|
|
524
|
+
ON UPDATE CASCADE
|
|
525
|
+
ON DELETE SET NULL
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
CREATE INDEX IF NOT EXISTS idx_backups_connection_created
|
|
529
|
+
ON backups(connectionId, createdAt DESC);
|
|
530
|
+
|
|
531
|
+
CREATE INDEX IF NOT EXISTS idx_backups_status
|
|
532
|
+
ON backups(status);
|
|
533
|
+
|
|
534
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_backups_path
|
|
535
|
+
ON backups(path);
|
|
473
536
|
`);
|
|
474
537
|
|
|
475
538
|
const recentConnectionColumns = new Set(
|
|
@@ -483,9 +546,27 @@ class AppStateStore {
|
|
|
483
546
|
this.db.exec("ALTER TABLE recent_connections ADD COLUMN logoPath TEXT");
|
|
484
547
|
}
|
|
485
548
|
|
|
549
|
+
this.ensureQueryRunsSchema();
|
|
486
550
|
this.ensureMediaTaggingConfigSchema();
|
|
487
551
|
}
|
|
488
552
|
|
|
553
|
+
ensureQueryRunsSchema() {
|
|
554
|
+
const queryRunColumns = new Set(
|
|
555
|
+
this.db
|
|
556
|
+
.prepare("PRAGMA table_info(query_runs)")
|
|
557
|
+
.all()
|
|
558
|
+
.map((column) => column.name)
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
if (!queryRunColumns.has("executed_by")) {
|
|
562
|
+
this.db.exec(`
|
|
563
|
+
ALTER TABLE query_runs
|
|
564
|
+
ADD COLUMN executed_by TEXT NOT NULL DEFAULT 'user'
|
|
565
|
+
CHECK(executed_by IN ('api', 'cli', 'user', 'mcp'))
|
|
566
|
+
`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
489
570
|
seedDefaultSettings() {
|
|
490
571
|
const insertSetting = this.db.prepare(`
|
|
491
572
|
INSERT INTO settings (key, value)
|
|
@@ -901,6 +982,7 @@ class AppStateStore {
|
|
|
901
982
|
id: Number(row.id),
|
|
902
983
|
historyId: Number(row.history_id ?? row.historyId),
|
|
903
984
|
executedAt: row.executed_at ?? row.executedAt ?? null,
|
|
985
|
+
executedBy: normalizeQueryExecutionSource(row.executed_by ?? row.executedBy),
|
|
904
986
|
durationMs: this.normalizeQueryHistoryInteger(row.duration_ms ?? row.durationMs),
|
|
905
987
|
rowCount: this.normalizeQueryHistoryInteger(row.row_count ?? row.rowCount),
|
|
906
988
|
status: row.status ?? "success",
|
|
@@ -924,6 +1006,7 @@ class AppStateStore {
|
|
|
924
1006
|
id: row.last_run_id ?? row.lastRunId,
|
|
925
1007
|
history_id: row.id,
|
|
926
1008
|
executed_at: row.last_run_executed_at ?? row.lastRunExecutedAt,
|
|
1009
|
+
executed_by: row.last_run_executed_by ?? row.lastRunExecutedBy,
|
|
927
1010
|
duration_ms: row.last_run_duration_ms ?? row.lastRunDurationMs,
|
|
928
1011
|
row_count: row.last_run_row_count ?? row.lastRunRowCount,
|
|
929
1012
|
status: row.last_run_status ?? row.lastRunStatus,
|
|
@@ -1161,6 +1244,7 @@ class AppStateStore {
|
|
|
1161
1244
|
q.last_used_at,
|
|
1162
1245
|
latest.id AS last_run_id,
|
|
1163
1246
|
latest.executed_at AS last_run_executed_at,
|
|
1247
|
+
latest.executed_by AS last_run_executed_by,
|
|
1164
1248
|
latest.duration_ms AS last_run_duration_ms,
|
|
1165
1249
|
latest.row_count AS last_run_row_count,
|
|
1166
1250
|
latest.status AS last_run_status,
|
|
@@ -1196,87 +1280,6 @@ class AppStateStore {
|
|
|
1196
1280
|
};
|
|
1197
1281
|
}
|
|
1198
1282
|
|
|
1199
|
-
migrateLegacySqlHistory() {
|
|
1200
|
-
if (this.getMetaValue(QUERY_HISTORY_MIGRATION_KEY) === "1") {
|
|
1201
|
-
return;
|
|
1202
|
-
}
|
|
1203
|
-
|
|
1204
|
-
const hasLegacyTable = Boolean(
|
|
1205
|
-
this.db
|
|
1206
|
-
.prepare(
|
|
1207
|
-
"SELECT 1 AS exists_flag FROM sqlite_master WHERE type = 'table' AND name = 'sql_history'"
|
|
1208
|
-
)
|
|
1209
|
-
.get()
|
|
1210
|
-
);
|
|
1211
|
-
|
|
1212
|
-
if (!hasLegacyTable) {
|
|
1213
|
-
this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
|
|
1214
|
-
return;
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
|
-
const legacyCount = Number(
|
|
1218
|
-
this.db.prepare("SELECT COUNT(*) AS count FROM sql_history").get()?.count ?? 0
|
|
1219
|
-
);
|
|
1220
|
-
|
|
1221
|
-
if (!legacyCount) {
|
|
1222
|
-
this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
|
|
1223
|
-
return;
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
const currentHistoryCount = Number(
|
|
1227
|
-
this.db.prepare("SELECT COUNT(*) AS count FROM query_history").get()?.count ?? 0
|
|
1228
|
-
);
|
|
1229
|
-
const currentRunCount = Number(
|
|
1230
|
-
this.db.prepare("SELECT COUNT(*) AS count FROM query_runs").get()?.count ?? 0
|
|
1231
|
-
);
|
|
1232
|
-
|
|
1233
|
-
if (currentHistoryCount > 0 || currentRunCount > 0) {
|
|
1234
|
-
this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
|
|
1235
|
-
return;
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
const legacyRows = this.db
|
|
1239
|
-
.prepare(`
|
|
1240
|
-
SELECT
|
|
1241
|
-
connectionId,
|
|
1242
|
-
connectionLabel,
|
|
1243
|
-
sql,
|
|
1244
|
-
timingMs,
|
|
1245
|
-
rowCount,
|
|
1246
|
-
affectedRowCount,
|
|
1247
|
-
executedAt
|
|
1248
|
-
FROM sql_history
|
|
1249
|
-
ORDER BY executedAt ASC, id ASC
|
|
1250
|
-
`)
|
|
1251
|
-
.all();
|
|
1252
|
-
|
|
1253
|
-
this.db.transaction(() => {
|
|
1254
|
-
legacyRows.forEach((entry) => {
|
|
1255
|
-
const databaseKey =
|
|
1256
|
-
this.normalizeQueryHistoryText(entry.connectionId) ??
|
|
1257
|
-
this.normalizeQueryHistoryText(entry.connectionLabel) ??
|
|
1258
|
-
"legacy:default";
|
|
1259
|
-
const rawSql = String(entry.sql ?? "");
|
|
1260
|
-
|
|
1261
|
-
if (!normalizeSql(rawSql)) {
|
|
1262
|
-
return;
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
this.recordQueryExecutionInTransaction({
|
|
1266
|
-
databaseKey,
|
|
1267
|
-
rawSql,
|
|
1268
|
-
status: "success",
|
|
1269
|
-
durationMs: entry.timingMs,
|
|
1270
|
-
rowCount: entry.rowCount,
|
|
1271
|
-
affectedRows: entry.affectedRowCount,
|
|
1272
|
-
executedAt: entry.executedAt ?? new Date().toISOString(),
|
|
1273
|
-
});
|
|
1274
|
-
});
|
|
1275
|
-
})();
|
|
1276
|
-
|
|
1277
|
-
this.setMetaValue(QUERY_HISTORY_MIGRATION_KEY, "1");
|
|
1278
|
-
}
|
|
1279
|
-
|
|
1280
1283
|
recordQueryExecution(entry = {}) {
|
|
1281
1284
|
return this.db.transaction(() => this.recordQueryExecutionInTransaction(entry))();
|
|
1282
1285
|
}
|
|
@@ -1290,10 +1293,12 @@ class AppStateStore {
|
|
|
1290
1293
|
affectedRows = null,
|
|
1291
1294
|
errorMessage = null,
|
|
1292
1295
|
executedAt = null,
|
|
1296
|
+
executedBy = "user",
|
|
1293
1297
|
} = {}) {
|
|
1294
1298
|
const normalizedDatabaseKey = this.normalizeQueryHistoryText(databaseKey);
|
|
1295
1299
|
const normalizedRawSql = String(rawSql ?? "");
|
|
1296
1300
|
const normalizedSql = normalizeSql(normalizedRawSql);
|
|
1301
|
+
const normalizedExecutedBy = requireQueryExecutionSource(executedBy);
|
|
1297
1302
|
|
|
1298
1303
|
if (!normalizedDatabaseKey) {
|
|
1299
1304
|
throw new ValidationError("Query history requires a database key.");
|
|
@@ -1382,9 +1387,10 @@ class AppStateStore {
|
|
|
1382
1387
|
row_count,
|
|
1383
1388
|
status,
|
|
1384
1389
|
error_message,
|
|
1385
|
-
affected_rows
|
|
1390
|
+
affected_rows,
|
|
1391
|
+
executed_by
|
|
1386
1392
|
)
|
|
1387
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1393
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1388
1394
|
`)
|
|
1389
1395
|
.run(
|
|
1390
1396
|
historyId,
|
|
@@ -1393,7 +1399,8 @@ class AppStateStore {
|
|
|
1393
1399
|
this.normalizeQueryHistoryInteger(rowCount),
|
|
1394
1400
|
status,
|
|
1395
1401
|
this.normalizeQueryHistoryText(errorMessage),
|
|
1396
|
-
this.normalizeQueryHistoryInteger(affectedRows)
|
|
1402
|
+
this.normalizeQueryHistoryInteger(affectedRows),
|
|
1403
|
+
normalizedExecutedBy
|
|
1397
1404
|
);
|
|
1398
1405
|
|
|
1399
1406
|
return historyId;
|
|
@@ -1438,6 +1445,7 @@ class AppStateStore {
|
|
|
1438
1445
|
charts.chart_types,
|
|
1439
1446
|
latest.id AS last_run_id,
|
|
1440
1447
|
latest.executed_at AS last_run_executed_at,
|
|
1448
|
+
latest.executed_by AS last_run_executed_by,
|
|
1441
1449
|
latest.duration_ms AS last_run_duration_ms,
|
|
1442
1450
|
latest.row_count AS last_run_row_count,
|
|
1443
1451
|
latest.status AS last_run_status,
|
|
@@ -1497,6 +1505,7 @@ class AppStateStore {
|
|
|
1497
1505
|
q.last_used_at,
|
|
1498
1506
|
latest.id AS last_run_id,
|
|
1499
1507
|
latest.executed_at AS last_run_executed_at,
|
|
1508
|
+
latest.executed_by AS last_run_executed_by,
|
|
1500
1509
|
latest.duration_ms AS last_run_duration_ms,
|
|
1501
1510
|
latest.row_count AS last_run_row_count,
|
|
1502
1511
|
latest.status AS last_run_status,
|
|
@@ -1551,6 +1560,7 @@ class AppStateStore {
|
|
|
1551
1560
|
q.last_used_at,
|
|
1552
1561
|
latest.id AS last_run_id,
|
|
1553
1562
|
latest.executed_at AS last_run_executed_at,
|
|
1563
|
+
latest.executed_by AS last_run_executed_by,
|
|
1554
1564
|
latest.duration_ms AS last_run_duration_ms,
|
|
1555
1565
|
latest.row_count AS last_run_row_count,
|
|
1556
1566
|
latest.status AS last_run_status,
|
|
@@ -1596,6 +1606,7 @@ class AppStateStore {
|
|
|
1596
1606
|
id,
|
|
1597
1607
|
history_id,
|
|
1598
1608
|
executed_at,
|
|
1609
|
+
executed_by,
|
|
1599
1610
|
duration_ms,
|
|
1600
1611
|
row_count,
|
|
1601
1612
|
status,
|
|
@@ -1948,6 +1959,286 @@ class AppStateStore {
|
|
|
1948
1959
|
.run(normalizedDatabaseKey).changes;
|
|
1949
1960
|
}
|
|
1950
1961
|
|
|
1962
|
+
decorateBackupRow(row = {}) {
|
|
1963
|
+
return {
|
|
1964
|
+
id: String(row.id ?? ""),
|
|
1965
|
+
connectionId: this.normalizeQueryHistoryText(row.connectionId),
|
|
1966
|
+
name: String(row.name ?? ""),
|
|
1967
|
+
notes: this.normalizeQueryHistoryText(row.notes),
|
|
1968
|
+
path: String(row.path ?? ""),
|
|
1969
|
+
sizeBytes: Number(row.sizeBytes ?? 0),
|
|
1970
|
+
status: String(row.status ?? "creating"),
|
|
1971
|
+
type: String(row.type ?? "manual"),
|
|
1972
|
+
sourcePath: String(row.sourcePath ?? ""),
|
|
1973
|
+
sourceLabel: this.normalizeQueryHistoryText(row.sourceLabel),
|
|
1974
|
+
sqliteHubVersion: this.normalizeQueryHistoryText(row.sqliteHubVersion),
|
|
1975
|
+
sqliteVersion: this.normalizeQueryHistoryText(row.sqliteVersion),
|
|
1976
|
+
journalMode: this.normalizeQueryHistoryText(row.journalMode),
|
|
1977
|
+
schemaVersion: this.normalizeQueryHistoryInteger(row.schemaVersion),
|
|
1978
|
+
tableCount: this.normalizeQueryHistoryInteger(row.tableCount),
|
|
1979
|
+
rowCount: this.normalizeQueryHistoryInteger(row.rowCount),
|
|
1980
|
+
checksumSha256: this.normalizeQueryHistoryText(row.checksumSha256),
|
|
1981
|
+
createdAt: row.createdAt ?? null,
|
|
1982
|
+
verifiedAt: row.verifiedAt ?? null,
|
|
1983
|
+
lastRestoredAt: row.lastRestoredAt ?? null,
|
|
1984
|
+
errorMessage: this.normalizeQueryHistoryText(row.errorMessage),
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
listBackups({ connectionId = null, includeAll = false } = {}) {
|
|
1989
|
+
const normalizedConnectionId = this.normalizeQueryHistoryText(connectionId);
|
|
1990
|
+
const whereSql = includeAll || !normalizedConnectionId ? "" : "WHERE connectionId = ?";
|
|
1991
|
+
const params = includeAll || !normalizedConnectionId ? [] : [normalizedConnectionId];
|
|
1992
|
+
|
|
1993
|
+
return this.db
|
|
1994
|
+
.prepare(
|
|
1995
|
+
`
|
|
1996
|
+
SELECT
|
|
1997
|
+
id,
|
|
1998
|
+
connectionId,
|
|
1999
|
+
name,
|
|
2000
|
+
notes,
|
|
2001
|
+
path,
|
|
2002
|
+
sizeBytes,
|
|
2003
|
+
status,
|
|
2004
|
+
type,
|
|
2005
|
+
sourcePath,
|
|
2006
|
+
sourceLabel,
|
|
2007
|
+
sqliteHubVersion,
|
|
2008
|
+
sqliteVersion,
|
|
2009
|
+
journalMode,
|
|
2010
|
+
schemaVersion,
|
|
2011
|
+
tableCount,
|
|
2012
|
+
rowCount,
|
|
2013
|
+
checksumSha256,
|
|
2014
|
+
createdAt,
|
|
2015
|
+
verifiedAt,
|
|
2016
|
+
lastRestoredAt,
|
|
2017
|
+
errorMessage
|
|
2018
|
+
FROM backups
|
|
2019
|
+
${whereSql}
|
|
2020
|
+
ORDER BY createdAt DESC, id ASC
|
|
2021
|
+
`
|
|
2022
|
+
)
|
|
2023
|
+
.all(...params)
|
|
2024
|
+
.map((row) => this.decorateBackupRow(row));
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
listBackupsByDirectory(directoryPath) {
|
|
2028
|
+
const normalizedDirectory = String(directoryPath ?? "").trim();
|
|
2029
|
+
|
|
2030
|
+
if (!normalizedDirectory) {
|
|
2031
|
+
return [];
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
return this.db
|
|
2035
|
+
.prepare(
|
|
2036
|
+
`
|
|
2037
|
+
SELECT
|
|
2038
|
+
id,
|
|
2039
|
+
connectionId,
|
|
2040
|
+
name,
|
|
2041
|
+
notes,
|
|
2042
|
+
path,
|
|
2043
|
+
sizeBytes,
|
|
2044
|
+
status,
|
|
2045
|
+
type,
|
|
2046
|
+
sourcePath,
|
|
2047
|
+
sourceLabel,
|
|
2048
|
+
sqliteHubVersion,
|
|
2049
|
+
sqliteVersion,
|
|
2050
|
+
journalMode,
|
|
2051
|
+
schemaVersion,
|
|
2052
|
+
tableCount,
|
|
2053
|
+
rowCount,
|
|
2054
|
+
checksumSha256,
|
|
2055
|
+
createdAt,
|
|
2056
|
+
verifiedAt,
|
|
2057
|
+
lastRestoredAt,
|
|
2058
|
+
errorMessage
|
|
2059
|
+
FROM backups
|
|
2060
|
+
WHERE path LIKE ?
|
|
2061
|
+
ORDER BY createdAt DESC, id ASC
|
|
2062
|
+
`
|
|
2063
|
+
)
|
|
2064
|
+
.all(`${normalizedDirectory}%`)
|
|
2065
|
+
.map((row) => this.decorateBackupRow(row));
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
getBackup(backupId) {
|
|
2069
|
+
const id = String(backupId ?? "").trim();
|
|
2070
|
+
|
|
2071
|
+
if (!id) {
|
|
2072
|
+
return null;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
const row = this.db
|
|
2076
|
+
.prepare(
|
|
2077
|
+
`
|
|
2078
|
+
SELECT
|
|
2079
|
+
id,
|
|
2080
|
+
connectionId,
|
|
2081
|
+
name,
|
|
2082
|
+
notes,
|
|
2083
|
+
path,
|
|
2084
|
+
sizeBytes,
|
|
2085
|
+
status,
|
|
2086
|
+
type,
|
|
2087
|
+
sourcePath,
|
|
2088
|
+
sourceLabel,
|
|
2089
|
+
sqliteHubVersion,
|
|
2090
|
+
sqliteVersion,
|
|
2091
|
+
journalMode,
|
|
2092
|
+
schemaVersion,
|
|
2093
|
+
tableCount,
|
|
2094
|
+
rowCount,
|
|
2095
|
+
checksumSha256,
|
|
2096
|
+
createdAt,
|
|
2097
|
+
verifiedAt,
|
|
2098
|
+
lastRestoredAt,
|
|
2099
|
+
errorMessage
|
|
2100
|
+
FROM backups
|
|
2101
|
+
WHERE id = ?
|
|
2102
|
+
`
|
|
2103
|
+
)
|
|
2104
|
+
.get(id);
|
|
2105
|
+
|
|
2106
|
+
return row ? this.decorateBackupRow(row) : null;
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
createBackupRecord(record = {}) {
|
|
2110
|
+
this.db
|
|
2111
|
+
.prepare(
|
|
2112
|
+
`
|
|
2113
|
+
INSERT INTO backups (
|
|
2114
|
+
id,
|
|
2115
|
+
connectionId,
|
|
2116
|
+
name,
|
|
2117
|
+
notes,
|
|
2118
|
+
path,
|
|
2119
|
+
sizeBytes,
|
|
2120
|
+
status,
|
|
2121
|
+
type,
|
|
2122
|
+
sourcePath,
|
|
2123
|
+
sourceLabel,
|
|
2124
|
+
sqliteHubVersion,
|
|
2125
|
+
sqliteVersion,
|
|
2126
|
+
journalMode,
|
|
2127
|
+
schemaVersion,
|
|
2128
|
+
tableCount,
|
|
2129
|
+
rowCount,
|
|
2130
|
+
checksumSha256,
|
|
2131
|
+
createdAt,
|
|
2132
|
+
verifiedAt,
|
|
2133
|
+
lastRestoredAt,
|
|
2134
|
+
errorMessage
|
|
2135
|
+
)
|
|
2136
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2137
|
+
`
|
|
2138
|
+
)
|
|
2139
|
+
.run(
|
|
2140
|
+
record.id,
|
|
2141
|
+
this.normalizeQueryHistoryText(record.connectionId),
|
|
2142
|
+
String(record.name ?? "Backup").trim() || "Backup",
|
|
2143
|
+
this.normalizeQueryHistoryText(record.notes),
|
|
2144
|
+
String(record.path ?? "").trim(),
|
|
2145
|
+
Number(record.sizeBytes ?? 0),
|
|
2146
|
+
record.status ?? "creating",
|
|
2147
|
+
record.type ?? "manual",
|
|
2148
|
+
String(record.sourcePath ?? "").trim(),
|
|
2149
|
+
this.normalizeQueryHistoryText(record.sourceLabel),
|
|
2150
|
+
this.normalizeQueryHistoryText(record.sqliteHubVersion),
|
|
2151
|
+
this.normalizeQueryHistoryText(record.sqliteVersion),
|
|
2152
|
+
this.normalizeQueryHistoryText(record.journalMode),
|
|
2153
|
+
this.normalizeQueryHistoryInteger(record.schemaVersion),
|
|
2154
|
+
this.normalizeQueryHistoryInteger(record.tableCount),
|
|
2155
|
+
this.normalizeQueryHistoryInteger(record.rowCount),
|
|
2156
|
+
this.normalizeQueryHistoryText(record.checksumSha256),
|
|
2157
|
+
record.createdAt ?? new Date().toISOString(),
|
|
2158
|
+
record.verifiedAt ?? null,
|
|
2159
|
+
record.lastRestoredAt ?? null,
|
|
2160
|
+
this.normalizeQueryHistoryText(record.errorMessage)
|
|
2161
|
+
);
|
|
2162
|
+
|
|
2163
|
+
return this.getBackup(record.id);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
updateBackupRecord(backupId, changes = {}) {
|
|
2167
|
+
const backup = this.getBackup(backupId);
|
|
2168
|
+
|
|
2169
|
+
if (!backup) {
|
|
2170
|
+
throw new NotFoundError(`Backup not found: ${backupId}`);
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
const next = {
|
|
2174
|
+
...backup,
|
|
2175
|
+
...changes,
|
|
2176
|
+
};
|
|
2177
|
+
|
|
2178
|
+
this.db
|
|
2179
|
+
.prepare(
|
|
2180
|
+
`
|
|
2181
|
+
UPDATE backups
|
|
2182
|
+
SET
|
|
2183
|
+
connectionId = ?,
|
|
2184
|
+
name = ?,
|
|
2185
|
+
notes = ?,
|
|
2186
|
+
path = ?,
|
|
2187
|
+
sizeBytes = ?,
|
|
2188
|
+
status = ?,
|
|
2189
|
+
type = ?,
|
|
2190
|
+
sourcePath = ?,
|
|
2191
|
+
sourceLabel = ?,
|
|
2192
|
+
sqliteHubVersion = ?,
|
|
2193
|
+
sqliteVersion = ?,
|
|
2194
|
+
journalMode = ?,
|
|
2195
|
+
schemaVersion = ?,
|
|
2196
|
+
tableCount = ?,
|
|
2197
|
+
rowCount = ?,
|
|
2198
|
+
checksumSha256 = ?,
|
|
2199
|
+
verifiedAt = ?,
|
|
2200
|
+
lastRestoredAt = ?,
|
|
2201
|
+
errorMessage = ?
|
|
2202
|
+
WHERE id = ?
|
|
2203
|
+
`
|
|
2204
|
+
)
|
|
2205
|
+
.run(
|
|
2206
|
+
this.normalizeQueryHistoryText(next.connectionId),
|
|
2207
|
+
String(next.name ?? "Backup").trim() || "Backup",
|
|
2208
|
+
this.normalizeQueryHistoryText(next.notes),
|
|
2209
|
+
String(next.path ?? "").trim(),
|
|
2210
|
+
Number(next.sizeBytes ?? 0),
|
|
2211
|
+
next.status ?? "creating",
|
|
2212
|
+
next.type ?? "manual",
|
|
2213
|
+
String(next.sourcePath ?? "").trim(),
|
|
2214
|
+
this.normalizeQueryHistoryText(next.sourceLabel),
|
|
2215
|
+
this.normalizeQueryHistoryText(next.sqliteHubVersion),
|
|
2216
|
+
this.normalizeQueryHistoryText(next.sqliteVersion),
|
|
2217
|
+
this.normalizeQueryHistoryText(next.journalMode),
|
|
2218
|
+
this.normalizeQueryHistoryInteger(next.schemaVersion),
|
|
2219
|
+
this.normalizeQueryHistoryInteger(next.tableCount),
|
|
2220
|
+
this.normalizeQueryHistoryInteger(next.rowCount),
|
|
2221
|
+
this.normalizeQueryHistoryText(next.checksumSha256),
|
|
2222
|
+
next.verifiedAt ?? null,
|
|
2223
|
+
next.lastRestoredAt ?? null,
|
|
2224
|
+
this.normalizeQueryHistoryText(next.errorMessage),
|
|
2225
|
+
backup.id
|
|
2226
|
+
);
|
|
2227
|
+
|
|
2228
|
+
return this.getBackup(backup.id);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
deleteBackupRecord(backupId) {
|
|
2232
|
+
const backup = this.getBackup(backupId);
|
|
2233
|
+
|
|
2234
|
+
if (!backup) {
|
|
2235
|
+
throw new NotFoundError(`Backup not found: ${backupId}`);
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
this.db.prepare("DELETE FROM backups WHERE id = ?").run(backup.id);
|
|
2239
|
+
return backup;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
1951
2242
|
trimRecentConnections() {
|
|
1952
2243
|
const maxRecentConnections = Number(
|
|
1953
2244
|
this.getSettings().maxRecentConnections ?? DEFAULT_STATE.settings.maxRecentConnections
|
|
@@ -2094,7 +2385,7 @@ class AppStateStore {
|
|
|
2094
2385
|
|
|
2095
2386
|
this.deleteConnectionLogo(existing?.logoPath);
|
|
2096
2387
|
|
|
2097
|
-
return this.
|
|
2388
|
+
return this.getRecentConnections();
|
|
2098
2389
|
}
|
|
2099
2390
|
|
|
2100
2391
|
updateRecentConnection(id, updater) {
|
|
@@ -41,7 +41,9 @@ async function startApi(t) {
|
|
|
41
41
|
return [{ name: "companies" }];
|
|
42
42
|
},
|
|
43
43
|
executeRawQuery(databaseId, sql, options = {}) {
|
|
44
|
-
serviceCalls.push(
|
|
44
|
+
serviceCalls.push(
|
|
45
|
+
`${databaseId}:query:${sql}:${options.storeName ?? ""}:${options.executedBy ?? ""}`
|
|
46
|
+
);
|
|
45
47
|
if (sql === "READONLY") {
|
|
46
48
|
throw new ReadOnlyError("Cannot execute raw SQL against a read-only database.");
|
|
47
49
|
}
|
|
@@ -66,6 +68,27 @@ async function startApi(t) {
|
|
|
66
68
|
},
|
|
67
69
|
};
|
|
68
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
|
+
},
|
|
69
92
|
};
|
|
70
93
|
const app = express();
|
|
71
94
|
|
|
@@ -211,7 +234,7 @@ test("query API executes raw SQL with a database token", async (t) => {
|
|
|
211
234
|
assert.equal(payload.metadata.stored, true);
|
|
212
235
|
assert.equal(payload.metadata.databaseId, fixture.databaseA.id);
|
|
213
236
|
assert.deepEqual(fixture.serviceCalls, [
|
|
214
|
-
`${fixture.databaseA.id}:query:SELECT 1:Stored API Query`,
|
|
237
|
+
`${fixture.databaseA.id}:query:SELECT 1:Stored API Query:api`,
|
|
215
238
|
]);
|
|
216
239
|
});
|
|
217
240
|
|
|
@@ -234,3 +257,23 @@ test("query API rejects read-only raw SQL execution", async (t) => {
|
|
|
234
257
|
assert.equal(response.status, 403);
|
|
235
258
|
assert.equal(payload.error.code, "SQLITE_READONLY");
|
|
236
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
|
+
});
|