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.
Files changed (60) hide show
  1. package/README.md +17 -196
  2. package/bin/sqlite-hub.js +7 -2
  3. package/frontend/js/api.js +60 -0
  4. package/frontend/js/app.js +201 -5
  5. package/frontend/js/components/dropdownButton.js +92 -0
  6. package/frontend/js/components/modal.js +991 -876
  7. package/frontend/js/components/queryEditor.js +24 -15
  8. package/frontend/js/components/queryHistoryDetail.js +20 -1
  9. package/frontend/js/components/queryHistoryHeader.js +3 -2
  10. package/frontend/js/components/queryResults.js +1 -1
  11. package/frontend/js/components/rowEditorPanel.js +53 -13
  12. package/frontend/js/components/sidebar.js +1 -0
  13. package/frontend/js/components/topNav.js +1 -4
  14. package/frontend/js/components/workspaceOpenDropdown.js +52 -0
  15. package/frontend/js/router.js +2 -0
  16. package/frontend/js/store.js +450 -38
  17. package/frontend/js/utils/emailPreview.js +28 -0
  18. package/frontend/js/utils/markdownDocuments.js +17 -1
  19. package/frontend/js/utils/riskySql.js +165 -0
  20. package/frontend/js/views/backups.js +204 -0
  21. package/frontend/js/views/charts.js +1 -1
  22. package/frontend/js/views/data.js +42 -16
  23. package/frontend/js/views/documents.js +184 -154
  24. package/frontend/js/views/settings.js +1 -0
  25. package/frontend/js/views/structure.js +47 -33
  26. package/frontend/js/views/tableDesigner.js +23 -0
  27. package/frontend/styles/components.css +133 -0
  28. package/frontend/styles/tailwind.generated.css +1 -1
  29. package/frontend/styles/tokens.css +1 -0
  30. package/frontend/styles/views.css +33 -21
  31. package/package.json +2 -1
  32. package/server/routes/backups.js +120 -0
  33. package/server/routes/connections.js +26 -3
  34. package/server/routes/externalApi.js +6 -2
  35. package/server/server.js +3 -1
  36. package/server/services/databaseCommandService.js +4 -3
  37. package/server/services/sqlite/backupService.js +497 -66
  38. package/server/services/sqlite/connectionManager.js +2 -2
  39. package/server/services/sqlite/importService.js +25 -0
  40. package/server/services/sqlite/sqlExecutor.js +2 -0
  41. package/server/services/storage/appStateStore.js +379 -88
  42. package/tests/api-token-auth.test.js +45 -2
  43. package/tests/backup-manager.test.js +140 -0
  44. package/tests/backups-view.test.js +70 -0
  45. package/tests/charts-height-preset-storage.test.js +60 -0
  46. package/tests/charts-route-state.test.js +144 -0
  47. package/tests/cli-service-delegation.test.js +97 -0
  48. package/tests/connection-removal.test.js +52 -0
  49. package/tests/database-command-service.test.js +37 -2
  50. package/tests/documents-view.test.js +132 -0
  51. package/tests/dropdown-button.test.js +101 -0
  52. package/tests/email-preview.test.js +89 -0
  53. package/tests/markdown-documents.test.js +24 -0
  54. package/tests/query-editor.test.js +28 -0
  55. package/tests/query-history-detail.test.js +37 -0
  56. package/tests/query-history-header.test.js +30 -0
  57. package/tests/risky-sql.test.js +30 -0
  58. package/tests/row-editor-null-values.test.js +53 -0
  59. package/tests/settings-view.test.js +1 -0
  60. package/tests/structure-view.test.js +60 -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.getState();
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(`${databaseId}:query:${sql}:${options.storeName ?? ""}`);
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
+ });