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.
Files changed (48) 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 +110 -0
  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/rowEditorPanel.js +1 -0
  11. package/frontend/js/components/sidebar.js +1 -0
  12. package/frontend/js/router.js +2 -0
  13. package/frontend/js/store.js +383 -37
  14. package/frontend/js/utils/riskySql.js +165 -0
  15. package/frontend/js/views/backups.js +176 -0
  16. package/frontend/js/views/charts.js +1 -1
  17. package/frontend/js/views/documents.js +184 -154
  18. package/frontend/js/views/structure.js +26 -24
  19. package/frontend/styles/components.css +128 -0
  20. package/frontend/styles/tailwind.generated.css +1 -1
  21. package/frontend/styles/views.css +33 -21
  22. package/package.json +2 -1
  23. package/server/routes/backups.js +120 -0
  24. package/server/routes/connections.js +26 -3
  25. package/server/routes/externalApi.js +6 -2
  26. package/server/server.js +3 -1
  27. package/server/services/databaseCommandService.js +4 -3
  28. package/server/services/sqlite/backupService.js +497 -66
  29. package/server/services/sqlite/connectionManager.js +2 -2
  30. package/server/services/sqlite/importService.js +25 -0
  31. package/server/services/sqlite/sqlExecutor.js +2 -0
  32. package/server/services/storage/appStateStore.js +379 -88
  33. package/tests/api-token-auth.test.js +45 -2
  34. package/tests/backup-manager.test.js +140 -0
  35. package/tests/backups-view.test.js +64 -0
  36. package/tests/charts-height-preset-storage.test.js +60 -0
  37. package/tests/charts-route-state.test.js +144 -0
  38. package/tests/cli-service-delegation.test.js +97 -0
  39. package/tests/connection-removal.test.js +52 -0
  40. package/tests/database-command-service.test.js +37 -2
  41. package/tests/documents-view.test.js +132 -0
  42. package/tests/dropdown-button.test.js +75 -0
  43. package/tests/query-editor.test.js +28 -0
  44. package/tests/query-history-detail.test.js +37 -0
  45. package/tests/query-history-header.test.js +30 -0
  46. package/tests/risky-sql.test.js +30 -0
  47. package/tests/row-editor-null-values.test.js +24 -0
  48. package/tests/structure-view.test.js +56 -0
@@ -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 }) {
@@ -3,6 +3,9 @@ const { ReadOnlyError, ValidationError, mapSqliteError } = require("../../utils/
3
3
  const { validateSqlDumpPath } = require("../../utils/fileValidation");
4
4
  const { splitSqlStatements } = require("./sqlExecutor");
5
5
 
6
+ const LARGE_IMPORT_ROW_THRESHOLD = 1000;
7
+ const LARGE_IMPORT_FILE_SIZE_THRESHOLD = 5 * 1024 * 1024;
8
+
6
9
  class ImportService {
7
10
  constructor({ connectionManager }) {
8
11
  this.connectionManager = connectionManager;
@@ -12,6 +15,26 @@ class ImportService {
12
15
  return /\b(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(sql);
13
16
  }
14
17
 
18
+ inspectSqlImport({ sqlFilePath }) {
19
+ const dumpPath = validateSqlDumpPath(sqlFilePath);
20
+ const stat = fs.statSync(dumpPath);
21
+ const sql = fs.readFileSync(dumpPath, "utf8");
22
+ const statementCount = splitSqlStatements(sql).length;
23
+
24
+ return {
25
+ sourceDumpPath: dumpPath,
26
+ sizeBytes: stat.size,
27
+ statementCount,
28
+ requiresSafetyBackup:
29
+ stat.size > LARGE_IMPORT_FILE_SIZE_THRESHOLD ||
30
+ statementCount > LARGE_IMPORT_ROW_THRESHOLD,
31
+ thresholds: {
32
+ rowCount: LARGE_IMPORT_ROW_THRESHOLD,
33
+ sizeBytes: LARGE_IMPORT_FILE_SIZE_THRESHOLD,
34
+ },
35
+ };
36
+ }
37
+
15
38
  importSql({
16
39
  sqlFilePath,
17
40
  targetPath,
@@ -107,5 +130,7 @@ class ImportService {
107
130
  }
108
131
 
109
132
  module.exports = {
133
+ LARGE_IMPORT_FILE_SIZE_THRESHOLD,
134
+ LARGE_IMPORT_ROW_THRESHOLD,
110
135
  ImportService,
111
136
  };
@@ -488,6 +488,7 @@ class SqlExecutor {
488
488
  rowCount: lastResultSet?.rowCount ?? null,
489
489
  affectedRows: totalChanges,
490
490
  errorMessage: normalizedError.message,
491
+ executedBy: options.executedBy,
491
492
  });
492
493
  } catch (recordingError) {
493
494
  console.warn(
@@ -524,6 +525,7 @@ class SqlExecutor {
524
525
  durationMs: timingMs,
525
526
  rowCount: lastResultSet?.rowCount ?? null,
526
527
  affectedRows: totalChanges,
528
+ executedBy: options.executedBy,
527
529
  });
528
530
  }
529
531