sqlite-hub 1.1.0 → 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.
Files changed (58) 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 +140 -10
  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 +29 -18
  8. package/frontend/js/components/queryHistoryDetail.js +20 -1
  9. package/frontend/js/components/queryHistoryHeader.js +3 -2
  10. package/frontend/js/components/rowEditorPanel.js +1 -0
  11. package/frontend/js/components/sidebar.js +1 -0
  12. package/frontend/js/components/structureGraph.js +1 -0
  13. package/frontend/js/components/tableDesignerEditor.js +23 -42
  14. package/frontend/js/components/tableDesignerSidebar.js +7 -31
  15. package/frontend/js/components/tableDesignerSqlPreview.js +2 -1
  16. package/frontend/js/router.js +2 -0
  17. package/frontend/js/store.js +407 -38
  18. package/frontend/js/utils/riskySql.js +165 -0
  19. package/frontend/js/views/backups.js +176 -0
  20. package/frontend/js/views/charts.js +12 -11
  21. package/frontend/js/views/data.js +37 -35
  22. package/frontend/js/views/documents.js +231 -162
  23. package/frontend/js/views/mediaTagging.js +6 -5
  24. package/frontend/js/views/structure.js +47 -43
  25. package/frontend/js/views/tableDesigner.js +45 -1
  26. package/frontend/styles/components.css +237 -59
  27. package/frontend/styles/structure-graph.css +10 -2
  28. package/frontend/styles/tailwind.generated.css +1 -1
  29. package/frontend/styles/tokens.css +2 -0
  30. package/frontend/styles/views.css +84 -41
  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/dataBrowserService.js +11 -3
  40. package/server/services/sqlite/importService.js +25 -0
  41. package/server/services/sqlite/sqlExecutor.js +2 -0
  42. package/server/services/storage/appStateStore.js +379 -88
  43. package/tests/api-token-auth.test.js +45 -2
  44. package/tests/backup-manager.test.js +140 -0
  45. package/tests/backups-view.test.js +64 -0
  46. package/tests/charts-height-preset-storage.test.js +60 -0
  47. package/tests/charts-route-state.test.js +144 -0
  48. package/tests/cli-service-delegation.test.js +97 -0
  49. package/tests/connection-removal.test.js +52 -0
  50. package/tests/database-command-service.test.js +38 -3
  51. package/tests/documents-view.test.js +132 -0
  52. package/tests/dropdown-button.test.js +75 -0
  53. package/tests/query-editor.test.js +28 -0
  54. package/tests/query-history-detail.test.js +37 -0
  55. package/tests/query-history-header.test.js +30 -0
  56. package/tests/risky-sql.test.js +30 -0
  57. package/tests/row-editor-null-values.test.js +24 -0
  58. package/tests/structure-view.test.js +56 -0
@@ -70,7 +70,10 @@ function createExternalApiRouter({ databaseService, tokenService, appInfoService
70
70
 
71
71
  authenticateDatabaseRequest(req, tokenService, databaseId);
72
72
 
73
- const { result } = databaseService.executeRawQuery(databaseId, sql, { storeName });
73
+ const { result } = databaseService.executeRawQuery(databaseId, sql, {
74
+ storeName,
75
+ executedBy: "api",
76
+ });
74
77
 
75
78
  res.json(
76
79
  successResponse({
@@ -168,7 +171,8 @@ function createExternalApiRouter({ databaseService, tokenService, appInfoService
168
171
  route((req, res) => {
169
172
  const { query, result } = databaseService.executeSavedQuery(
170
173
  req.params.databaseId,
171
- req.params.queryName
174
+ req.params.queryName,
175
+ { executedBy: "api" }
172
176
  );
173
177
 
174
178
  res.json(
package/server/server.js CHANGED
@@ -24,6 +24,7 @@ const { MediaTaggingService } = require("./services/sqlite/mediaTaggingService")
24
24
  const { ApiTokenService } = require("./services/apiTokenService");
25
25
  const { DatabaseCommandService } = require("./services/databaseCommandService");
26
26
  const { createConnectionsRouter } = require("./routes/connections");
27
+ const { createBackupsRouter } = require("./routes/backups");
27
28
  const { createOverviewRouter } = require("./routes/overview");
28
29
  const { createSqlRouter } = require("./routes/sql");
29
30
  const { createChartsRouter } = require("./routes/charts");
@@ -56,7 +57,7 @@ const connectionManager = new ConnectionManager({ appStateStore });
56
57
  const overviewService = new OverviewService({ connectionManager });
57
58
  const sqlExecutor = new SqlExecutor({ connectionManager, appStateStore });
58
59
  const importService = new ImportService({ connectionManager });
59
- const backupService = new BackupService({ connectionManager });
60
+ const backupService = new BackupService({ connectionManager, appStateStore });
60
61
  const nativeFileDialogService = new NativeFileDialogService();
61
62
  const exportService = new ExportService({
62
63
  appStateStore,
@@ -114,6 +115,7 @@ app.use(
114
115
  nativeFileDialogService,
115
116
  })
116
117
  );
118
+ app.use("/api/backups", createBackupsRouter({ backupService }));
117
119
  app.use("/api/db", createOverviewRouter({ overviewService }));
118
120
  app.use("/api/sql", createSqlRouter({ appStateStore, connectionManager, sqlExecutor }));
119
121
  app.use("/api/charts", createChartsRouter({ appStateStore, connectionManager, sqlExecutor }));
@@ -283,7 +283,7 @@ class DatabaseCommandService {
283
283
  const collection = this.appStateStore.buildQueryHistoryCollection({
284
284
  databaseKey: connection.id,
285
285
  search: queryName,
286
- onlySaved: false,
286
+ onlySaved: true,
287
287
  limit: 100,
288
288
  });
289
289
 
@@ -326,10 +326,11 @@ class DatabaseCommandService {
326
326
  return this.requireQuery(databaseReference, queryName);
327
327
  }
328
328
 
329
- executeSavedQuery(databaseReference, queryName) {
329
+ executeSavedQuery(databaseReference, queryName, options = {}) {
330
330
  const query = this.requireQuery(databaseReference, queryName);
331
+ const { executedBy = "user" } = options;
331
332
  const result = this.withDatabase(databaseReference, ({ runtime }) =>
332
- runtime.sqlExecutor.execute(query.rawSql, { persistHistory: false })
333
+ runtime.sqlExecutor.execute(query.rawSql, { executedBy })
333
334
  );
334
335
 
335
336
  return { query, result };
@@ -1,112 +1,543 @@
1
+ const crypto = require("node:crypto");
1
2
  const fs = require("node:fs");
2
3
  const path = require("node:path");
3
- const { DatabaseRequiredError } = require("../../utils/errors");
4
+ const Database = require("better-sqlite3");
5
+ const { version: sqliteHubVersion } = require("../../../package.json");
6
+ const {
7
+ AppError,
8
+ ConflictError,
9
+ DatabaseRequiredError,
10
+ NotFoundError,
11
+ ReadOnlyError,
12
+ ValidationError,
13
+ mapSqliteError,
14
+ } = require("../../utils/errors");
4
15
  const {
5
- SQLITE_EXTENSIONS,
6
16
  ensureParentDirectory,
7
17
  getFileMetadata,
18
+ resolvePathInsideDirectory,
8
19
  validateSqlitePath,
9
20
  } = require("../../utils/fileValidation");
21
+ const { quoteIdentifier } = require("../../utils/identifier");
22
+
23
+ const BACKUP_TYPES = new Set([
24
+ "manual",
25
+ "automatic",
26
+ "pre_restore",
27
+ "pre_migration",
28
+ "pre_import",
29
+ "pre_schema_change",
30
+ ]);
31
+ const ACTIVE_BACKUP_STATUSES = new Set(["creating", "verifying", "restoring"]);
32
+ const ROW_COUNT_SIZE_THRESHOLD_BYTES = 20 * 1024 * 1024;
10
33
 
11
- function padTimestampPart(value) {
12
- return String(value).padStart(2, "0");
34
+ function toBackupTimestamp(date = new Date()) {
35
+ return date.toISOString().replace(/\.\d{3}Z$/, "").replaceAll(":", "-");
13
36
  }
14
37
 
15
- function formatBackupTimestamp(date) {
16
- return [
17
- date.getFullYear(),
18
- padTimestampPart(date.getMonth() + 1),
19
- padTimestampPart(date.getDate()),
20
- ].join("-")
21
- + "_"
22
- + [
23
- padTimestampPart(date.getHours()),
24
- padTimestampPart(date.getMinutes()),
25
- padTimestampPart(date.getSeconds()),
26
- ].join("-");
38
+ function formatDisplayDate(date = new Date()) {
39
+ return new Intl.DateTimeFormat("de-AT", {
40
+ day: "2-digit",
41
+ month: "2-digit",
42
+ year: "numeric",
43
+ hour: "2-digit",
44
+ minute: "2-digit",
45
+ }).format(date);
27
46
  }
28
47
 
29
- function sanitizeBackupBaseName(value) {
30
- const normalized = String(value ?? "").trim().replace(/[<>:"/\\|?*\u0000-\u001F]+/g, "_");
31
- return normalized || "database";
48
+ function normalizeBackupType(value) {
49
+ const normalized = String(value ?? "manual").trim();
50
+ return BACKUP_TYPES.has(normalized) ? normalized : "manual";
51
+ }
52
+
53
+ function sanitizePathSegment(value, fallback = "backup") {
54
+ const normalized = String(value ?? "")
55
+ .trim()
56
+ .replace(/[^a-zA-Z0-9._-]+/g, "_")
57
+ .replace(/^_+|_+$/g, "");
58
+
59
+ return normalized || fallback;
32
60
  }
33
61
 
34
- function normalizeBackupLabelBase(value, fallback) {
35
- const normalized = String(value ?? "").trim() || String(fallback ?? "").trim() || "database";
36
- const extension = path.extname(normalized).toLowerCase();
62
+ function buildDefaultBackupName(type, date = new Date(), context = "") {
63
+ const suffix = context ? ` ${context}` : "";
64
+
65
+ if (type === "pre_restore") {
66
+ return `Before restore${suffix}`;
67
+ }
68
+
69
+ if (type === "pre_import") {
70
+ return `Before import${suffix}`;
71
+ }
37
72
 
38
- if (SQLITE_EXTENSIONS.has(extension)) {
39
- return normalized.slice(0, -extension.length) || "database";
73
+ if (type === "pre_migration") {
74
+ return "Before migration";
40
75
  }
41
76
 
42
- return normalized;
77
+ if (type === "pre_schema_change") {
78
+ return `Before schema change${suffix}`;
79
+ }
80
+
81
+ return `Manual backup - ${formatDisplayDate(date)}`;
82
+ }
83
+
84
+ function hashFileSha256(filePath) {
85
+ return new Promise((resolve, reject) => {
86
+ const hash = crypto.createHash("sha256");
87
+ const stream = fs.createReadStream(filePath);
88
+
89
+ stream.on("error", reject);
90
+ stream.on("data", (chunk) => hash.update(chunk));
91
+ stream.on("end", () => resolve(hash.digest("hex")));
92
+ });
93
+ }
94
+
95
+ function writeJsonAtomic(filePath, value) {
96
+ ensureParentDirectory(filePath);
97
+ const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
98
+
99
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`);
100
+ fs.renameSync(temporaryPath, filePath);
101
+ }
102
+
103
+ function readQuickCheckResult(db) {
104
+ const row = db.prepare("PRAGMA quick_check").get();
105
+ const value = Object.values(row ?? {})[0];
106
+ return String(value ?? "").trim().toLowerCase();
43
107
  }
44
108
 
45
109
  class BackupService {
46
- constructor({ connectionManager }) {
110
+ constructor({ connectionManager, appStateStore, backupRootDirectory = null }) {
47
111
  this.connectionManager = connectionManager;
112
+ this.appStateStore = appStateStore;
113
+ this.backupRootDirectory =
114
+ backupRootDirectory ?? path.join(appStateStore.stateDirectory, "backups");
115
+ this.activeOperations = new Set();
116
+ }
117
+
118
+ getBackupDirectory(connectionId) {
119
+ return path.join(this.backupRootDirectory, sanitizePathSegment(connectionId, "database"));
120
+ }
121
+
122
+ getManifestPathForDirectory(directoryPath) {
123
+ return path.join(directoryPath, "manifest.json");
124
+ }
125
+
126
+ assertBackupPathInsideRoot(filePath) {
127
+ return resolvePathInsideDirectory(this.backupRootDirectory, filePath, "Backup path");
128
+ }
129
+
130
+ async withOperation(lockKey, callback) {
131
+ if (this.activeOperations.has(lockKey)) {
132
+ throw new ConflictError("A backup operation is already running for this target.");
133
+ }
134
+
135
+ this.activeOperations.add(lockKey);
136
+
137
+ try {
138
+ return await callback();
139
+ } finally {
140
+ this.activeOperations.delete(lockKey);
141
+ }
142
+ }
143
+
144
+ listBackups({ connectionId = null, includeAll = false } = {}) {
145
+ const activeConnection = this.connectionManager.getActiveConnection();
146
+ const targetConnectionId = connectionId ?? activeConnection?.id ?? null;
147
+
148
+ return this.appStateStore.listBackups({
149
+ connectionId: targetConnectionId,
150
+ includeAll,
151
+ }).map((backup) => this.decorateBackupFileState(backup));
48
152
  }
49
153
 
50
- createActiveBackup() {
154
+ getBackup(backupId) {
155
+ const backup = this.appStateStore.getBackup(backupId);
156
+
157
+ if (!backup) {
158
+ throw new NotFoundError(`Backup not found: ${backupId}`);
159
+ }
160
+
161
+ return this.decorateBackupFileState(backup);
162
+ }
163
+
164
+ updateBackupNotes(backupId, notes = "") {
165
+ const backup = this.appStateStore.updateBackupRecord(backupId, {
166
+ notes,
167
+ });
168
+
169
+ this.updateManifestForBackup(backup);
170
+ return this.decorateBackupFileState(backup);
171
+ }
172
+
173
+ decorateBackupFileState(backup) {
174
+ const fileExists = Boolean(backup.path && fs.existsSync(backup.path));
175
+
176
+ return {
177
+ ...backup,
178
+ fileExists,
179
+ fileName: backup.path ? path.basename(backup.path) : "",
180
+ directory: backup.path ? path.dirname(backup.path) : "",
181
+ };
182
+ }
183
+
184
+ async createActiveBackup(options = {}) {
51
185
  const activeConnection = this.connectionManager.getActiveConnection();
52
186
 
53
187
  if (!activeConnection) {
54
188
  throw new DatabaseRequiredError("No active SQLite database selected for backup.");
55
189
  }
56
190
 
57
- const sourcePath = validateSqlitePath(activeConnection.path, { mustExist: true });
58
- const { backupPath, connectionLabel } = this.buildBackupPath(sourcePath, activeConnection.label);
191
+ return this.createBackupForConnection(activeConnection, options);
192
+ }
59
193
 
60
- ensureParentDirectory(backupPath);
61
- fs.copyFileSync(sourcePath, backupPath, fs.constants.COPYFILE_EXCL);
194
+ async createBackupForConnection(connection, options = {}) {
195
+ if (!connection?.id) {
196
+ throw new DatabaseRequiredError("No active SQLite database selected for backup.");
197
+ }
198
+
199
+ const type = normalizeBackupType(options.type);
200
+ const createdAtDate = new Date();
201
+ const createdAt = createdAtDate.toISOString();
202
+ const backupName =
203
+ String(options.name ?? "").trim() ||
204
+ buildDefaultBackupName(type, createdAtDate, options.context);
205
+ const sourcePath = validateSqlitePath(connection.path, { mustExist: true });
206
+ const backupDirectory = this.getBackupDirectory(connection.id);
207
+ const backupPath = path.join(backupDirectory, `backup-${toBackupTimestamp(createdAtDate)}.sqlite`);
208
+
209
+ return this.withOperation(`connection:${connection.id}`, async () => {
210
+ ensureParentDirectory(backupPath);
211
+
212
+ const record = this.appStateStore.createBackupRecord({
213
+ id: crypto.randomUUID(),
214
+ connectionId: connection.id,
215
+ name: backupName,
216
+ notes: options.notes,
217
+ path: backupPath,
218
+ status: "creating",
219
+ type,
220
+ sourcePath,
221
+ sourceLabel: connection.label,
222
+ sqliteHubVersion,
223
+ createdAt,
224
+ });
225
+
226
+ this.updateManifestForBackup(record);
227
+
228
+ try {
229
+ const sourceDb = this.connectionManager.getActiveDatabase();
230
+
231
+ if (!sourceDb || this.connectionManager.getActiveConnection()?.id !== connection.id) {
232
+ throw new DatabaseRequiredError("The active database changed before backup creation.");
233
+ }
62
234
 
63
- const backupConnection = this.connectionManager.rememberConnection({
64
- filePath: backupPath,
65
- label: connectionLabel,
66
- makeActive: false,
235
+ await sourceDb.backup(backupPath);
236
+
237
+ const sourceMetadata = this.collectSourceMetadata(sourceDb, sourcePath);
238
+ const sizeBytes = getFileMetadata(backupPath).sizeBytes;
239
+ const checksumSha256 = await hashFileSha256(backupPath);
240
+ let updated = this.appStateStore.updateBackupRecord(record.id, {
241
+ ...sourceMetadata,
242
+ sizeBytes,
243
+ checksumSha256,
244
+ status: "verifying",
245
+ errorMessage: null,
246
+ });
247
+ this.updateManifestForBackup(updated);
248
+
249
+ updated = this.verifyBackupRecord(updated.id);
250
+ this.updateManifestForBackup(updated);
251
+ return this.decorateBackupFileState(updated);
252
+ } catch (error) {
253
+ const normalized = mapSqliteError(error);
254
+ const failed = this.appStateStore.updateBackupRecord(record.id, {
255
+ status: "failed",
256
+ errorMessage: normalized.message,
257
+ });
258
+ this.updateManifestForBackup(failed);
259
+ throw normalized;
260
+ }
67
261
  });
68
- const metadata = getFileMetadata(backupPath);
262
+ }
263
+
264
+ collectSourceMetadata(db, sourcePath) {
265
+ const metadata = {
266
+ sqliteHubVersion,
267
+ sqliteVersion: null,
268
+ journalMode: null,
269
+ schemaVersion: null,
270
+ tableCount: null,
271
+ rowCount: null,
272
+ };
273
+
274
+ try {
275
+ metadata.sqliteVersion = db.prepare("SELECT sqlite_version() AS version").get()?.version ?? null;
276
+ } catch {}
277
+
278
+ try {
279
+ const journalModeRow = db.prepare("PRAGMA journal_mode").get();
280
+ metadata.journalMode = Object.values(journalModeRow ?? {})[0] ?? null;
281
+ } catch {}
282
+
283
+ try {
284
+ const userVersionRow = db.prepare("PRAGMA user_version").get();
285
+ metadata.schemaVersion = Number(Object.values(userVersionRow ?? {})[0] ?? 0);
286
+ } catch {}
287
+
288
+ let tableNames = [];
289
+ try {
290
+ tableNames = db
291
+ .prepare(
292
+ "SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
293
+ )
294
+ .all()
295
+ .map((row) => row.name);
296
+ metadata.tableCount = tableNames.length;
297
+ } catch {}
298
+
299
+ try {
300
+ const sourceSize = fs.statSync(sourcePath).size;
301
+ if (sourceSize <= ROW_COUNT_SIZE_THRESHOLD_BYTES) {
302
+ metadata.rowCount = tableNames.reduce((sum, tableName) => {
303
+ const row = db.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdentifier(tableName)}`).get();
304
+ return sum + Number(row?.count ?? 0);
305
+ }, 0);
306
+ }
307
+ } catch {
308
+ metadata.rowCount = null;
309
+ }
310
+
311
+ return metadata;
312
+ }
313
+
314
+ verifyBackupRecord(backupId) {
315
+ const backup = this.getBackup(backupId);
316
+
317
+ if (!backup.fileExists) {
318
+ return this.appStateStore.updateBackupRecord(backup.id, {
319
+ status: "failed",
320
+ errorMessage: "Backup file is missing.",
321
+ });
322
+ }
323
+
324
+ let db = null;
325
+
326
+ try {
327
+ db = new Database(backup.path, {
328
+ readonly: true,
329
+ fileMustExist: true,
330
+ });
331
+ const quickCheck = readQuickCheckResult(db);
332
+
333
+ if (quickCheck !== "ok") {
334
+ throw new ValidationError(`Backup verification failed: ${quickCheck}`);
335
+ }
336
+
337
+ return this.appStateStore.updateBackupRecord(backup.id, {
338
+ status: "verified",
339
+ verifiedAt: new Date().toISOString(),
340
+ errorMessage: null,
341
+ });
342
+ } catch (error) {
343
+ const normalized = mapSqliteError(error);
344
+ return this.appStateStore.updateBackupRecord(backup.id, {
345
+ status: "failed",
346
+ errorMessage: normalized.message,
347
+ });
348
+ } finally {
349
+ db?.close();
350
+ }
351
+ }
352
+
353
+ updateManifestForBackup(backup) {
354
+ const directoryPath = path.dirname(backup.path);
355
+ const manifestPath = this.getManifestPathForDirectory(directoryPath);
356
+ const backups = this.appStateStore.listBackupsByDirectory(directoryPath);
357
+ const manifest = {
358
+ databaseId: backup.connectionId ?? path.basename(directoryPath),
359
+ sourcePath: backup.sourcePath,
360
+ sourceLabel: backup.sourceLabel,
361
+ updatedAt: new Date().toISOString(),
362
+ backups: backups.map((entry) => ({
363
+ id: entry.id,
364
+ name: entry.name,
365
+ notes: entry.notes,
366
+ fileName: path.basename(entry.path),
367
+ sizeBytes: entry.sizeBytes,
368
+ type: entry.type,
369
+ status: entry.status,
370
+ createdAt: entry.createdAt,
371
+ verifiedAt: entry.verifiedAt,
372
+ checksumSha256: entry.checksumSha256,
373
+ lastRestoredAt: entry.lastRestoredAt,
374
+ errorMessage: entry.errorMessage,
375
+ })),
376
+ };
377
+
378
+ writeJsonAtomic(manifestPath, manifest);
379
+ }
380
+
381
+ getDownloadInfo(backupId) {
382
+ const backup = this.getBackup(backupId);
383
+
384
+ if (!backup.fileExists) {
385
+ throw new NotFoundError("Backup file is missing.");
386
+ }
69
387
 
70
388
  return {
71
- sourcePath,
72
- backupPath,
73
- directory: path.dirname(backupPath),
74
- fileName: path.basename(backupPath),
75
- createdAt: new Date().toISOString(),
76
- sizeBytes: metadata.sizeBytes,
77
- connection: backupConnection,
389
+ path: backup.path,
390
+ filename: `${sanitizePathSegment(backup.name, "backup")}_${toBackupTimestamp(
391
+ new Date(backup.createdAt ?? Date.now())
392
+ )}.sqlite`,
78
393
  };
79
394
  }
80
395
 
81
- buildBackupPath(sourcePath, sourceLabel) {
82
- const parsedSource = path.parse(sourcePath);
83
- const backupDirectory = path.join(parsedSource.dir, "backups");
84
- const extension = parsedSource.ext || ".sqlite";
85
- const displayBaseName = normalizeBackupLabelBase(sourceLabel, parsedSource.name);
86
- const fileBaseName = sanitizeBackupBaseName(displayBaseName);
87
- const timestamp = formatBackupTimestamp(new Date());
88
- let attempt = 1;
89
-
90
- while (true) {
91
- const suffix = attempt === 1 ? "" : `_${String(attempt).padStart(2, "0")}`;
92
- const fileStem = `${fileBaseName}_${timestamp}${suffix}`;
93
- const candidate = path.join(
94
- backupDirectory,
95
- `${fileStem}${extension}`
96
- );
97
-
98
- if (!fs.existsSync(candidate)) {
99
- return {
100
- backupPath: candidate,
101
- connectionLabel: `${displayBaseName}_${timestamp}${suffix}`,
102
- };
396
+ deleteBackup(backupId) {
397
+ const backup = this.getBackup(backupId);
398
+
399
+ if (ACTIVE_BACKUP_STATUSES.has(backup.status)) {
400
+ throw new ConflictError("Backup is currently busy and cannot be deleted.");
401
+ }
402
+
403
+ if (backup.fileExists) {
404
+ fs.rmSync(backup.path, { force: false });
405
+ }
406
+
407
+ const deleted = this.appStateStore.deleteBackupRecord(backup.id);
408
+ this.updateManifestAfterDelete(deleted);
409
+ this.removeEmptyBackupDirectory(path.dirname(deleted.path));
410
+ return deleted;
411
+ }
412
+
413
+ updateManifestAfterDelete(deletedBackup) {
414
+ const directoryPath = path.dirname(deletedBackup.path);
415
+ const remaining = this.appStateStore.listBackupsByDirectory(directoryPath);
416
+
417
+ if (!remaining.length) {
418
+ const manifestPath = this.getManifestPathForDirectory(directoryPath);
419
+ if (fs.existsSync(manifestPath)) {
420
+ fs.rmSync(manifestPath, { force: true });
103
421
  }
422
+ return;
423
+ }
104
424
 
105
- attempt += 1;
425
+ this.updateManifestForBackup(remaining[0]);
426
+ }
427
+
428
+ removeEmptyBackupDirectory(directoryPath) {
429
+ try {
430
+ if (fs.existsSync(directoryPath) && !fs.readdirSync(directoryPath).length) {
431
+ fs.rmdirSync(directoryPath);
432
+ }
433
+ } catch {}
434
+ }
435
+
436
+ restoreBackup(backupId) {
437
+ const activeConnection = this.connectionManager.getActiveConnection();
438
+
439
+ if (!activeConnection) {
440
+ throw new DatabaseRequiredError("No active SQLite database selected for restore.");
441
+ }
442
+
443
+ if (activeConnection.readOnly) {
444
+ throw new ReadOnlyError("Cannot restore into a read-only database.");
445
+ }
446
+
447
+ const backup = this.getBackup(backupId);
448
+
449
+ if (backup.status !== "verified") {
450
+ throw new ValidationError("Only verified backups can be restored.");
451
+ }
452
+
453
+ if (!backup.fileExists) {
454
+ throw new NotFoundError("Backup file is missing.");
455
+ }
456
+
457
+ if (backup.connectionId && backup.connectionId !== activeConnection.id) {
458
+ throw new ValidationError("Backup does not belong to the active database.");
106
459
  }
460
+
461
+ return this.withOperation(`restore:${backup.id}`, async () => {
462
+ const verified = this.verifyBackupRecord(backup.id);
463
+ this.updateManifestForBackup(verified);
464
+
465
+ if (verified.status !== "verified") {
466
+ throw new ValidationError(verified.errorMessage || "Backup verification failed.");
467
+ }
468
+
469
+ const targetPath = activeConnection.path;
470
+ const rollbackPath = `${targetPath}.sqlite-hub-restore-${Date.now()}.bak`;
471
+ const temporaryRestorePath = `${targetPath}.sqlite-hub-restore-${Date.now()}.sqlite`;
472
+ let restored = null;
473
+
474
+ try {
475
+ fs.copyFileSync(backup.path, temporaryRestorePath, fs.constants.COPYFILE_EXCL);
476
+ validateSqlitePath(temporaryRestorePath, { mustExist: true });
477
+
478
+ this.connectionManager.closeCurrent();
479
+ fs.renameSync(targetPath, rollbackPath);
480
+ fs.renameSync(temporaryRestorePath, targetPath);
481
+
482
+ this.connectionManager.openConnection({
483
+ filePath: targetPath,
484
+ label: activeConnection.label,
485
+ id: activeConnection.id,
486
+ makeActive: true,
487
+ readOnly: false,
488
+ logoPath: activeConnection.logoPath ?? null,
489
+ });
490
+
491
+ const db = this.connectionManager.getActiveDatabase();
492
+ const quickCheck = readQuickCheckResult(db);
493
+
494
+ if (quickCheck !== "ok") {
495
+ throw new ValidationError(`Restored database verification failed: ${quickCheck}`);
496
+ }
497
+
498
+ fs.rmSync(rollbackPath, { force: true });
499
+ restored = this.appStateStore.updateBackupRecord(backup.id, {
500
+ status: "verified",
501
+ lastRestoredAt: new Date().toISOString(),
502
+ errorMessage: null,
503
+ });
504
+ this.updateManifestForBackup(restored);
505
+ return this.decorateBackupFileState(restored);
506
+ } catch (error) {
507
+ try {
508
+ if (fs.existsSync(temporaryRestorePath)) {
509
+ fs.rmSync(temporaryRestorePath, { force: true });
510
+ }
511
+
512
+ if (fs.existsSync(rollbackPath)) {
513
+ if (fs.existsSync(targetPath)) {
514
+ fs.rmSync(targetPath, { force: true });
515
+ }
516
+ fs.renameSync(rollbackPath, targetPath);
517
+ }
518
+
519
+ this.connectionManager.openConnection({
520
+ filePath: targetPath,
521
+ label: activeConnection.label,
522
+ id: activeConnection.id,
523
+ makeActive: true,
524
+ readOnly: false,
525
+ logoPath: activeConnection.logoPath ?? null,
526
+ });
527
+ } catch (reopenError) {
528
+ console.warn(`Failed to reopen database after restore failure: ${reopenError.message}`);
529
+ }
530
+
531
+ throw mapSqliteError(error);
532
+ }
533
+ });
107
534
  }
108
535
  }
109
536
 
110
537
  module.exports = {
538
+ BACKUP_TYPES,
111
539
  BackupService,
540
+ buildDefaultBackupName,
541
+ sanitizePathSegment,
542
+ toBackupTimestamp,
112
543
  };
@@ -207,13 +207,13 @@ class ConnectionManager {
207
207
  }
208
208
 
209
209
  removeRecentConnection(id) {
210
- const state = this.appStateStore.removeRecentConnection(id);
210
+ const recentConnections = this.appStateStore.removeRecentConnection(id);
211
211
 
212
212
  if (this.current?.id === id) {
213
213
  this.closeCurrent();
214
214
  }
215
215
 
216
- return state.recentConnections;
216
+ return recentConnections;
217
217
  }
218
218
 
219
219
  updateRecentConnection(id, { filePath, label, readOnly = false, logoUpload = null, clearLogo = false }) {