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.
- package/README.md +17 -196
- package/bin/sqlite-hub.js +7 -2
- package/frontend/js/api.js +60 -0
- package/frontend/js/app.js +140 -10
- package/frontend/js/components/dropdownButton.js +92 -0
- package/frontend/js/components/modal.js +991 -876
- package/frontend/js/components/queryEditor.js +29 -18
- 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/components/structureGraph.js +1 -0
- package/frontend/js/components/tableDesignerEditor.js +23 -42
- package/frontend/js/components/tableDesignerSidebar.js +7 -31
- package/frontend/js/components/tableDesignerSqlPreview.js +2 -1
- package/frontend/js/router.js +2 -0
- package/frontend/js/store.js +407 -38
- package/frontend/js/utils/riskySql.js +165 -0
- package/frontend/js/views/backups.js +176 -0
- package/frontend/js/views/charts.js +12 -11
- package/frontend/js/views/data.js +37 -35
- package/frontend/js/views/documents.js +231 -162
- package/frontend/js/views/mediaTagging.js +6 -5
- package/frontend/js/views/structure.js +47 -43
- package/frontend/js/views/tableDesigner.js +45 -1
- package/frontend/styles/components.css +237 -59
- package/frontend/styles/structure-graph.css +10 -2
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/tokens.css +2 -0
- package/frontend/styles/views.css +84 -41
- 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/dataBrowserService.js +11 -3
- 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 +38 -3
- 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
|
@@ -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, {
|
|
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:
|
|
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, {
|
|
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
|
|
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
|
|
12
|
-
return
|
|
34
|
+
function toBackupTimestamp(date = new Date()) {
|
|
35
|
+
return date.toISOString().replace(/\.\d{3}Z$/, "").replaceAll(":", "-");
|
|
13
36
|
}
|
|
14
37
|
|
|
15
|
-
function
|
|
16
|
-
return
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
30
|
-
const normalized = String(value ?? "").trim()
|
|
31
|
-
return normalized
|
|
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
|
|
35
|
-
const
|
|
36
|
-
|
|
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 (
|
|
39
|
-
return
|
|
73
|
+
if (type === "pre_migration") {
|
|
74
|
+
return "Before migration";
|
|
40
75
|
}
|
|
41
76
|
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
191
|
+
return this.createBackupForConnection(activeConnection, options);
|
|
192
|
+
}
|
|
59
193
|
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
216
|
+
return recentConnections;
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
updateRecentConnection(id, { filePath, label, readOnly = false, logoUpload = null, clearLogo = false }) {
|