From 2f960f104c4ec1e997a589c941501998c6c202cb Mon Sep 17 00:00:00 2001
From: CSXFanMeng <105690322+CSXFanMeng@users.noreply.github.com>
Date: Wed, 23 Sep 2026 12:29:56 +0800
Subject: [PATCH] feat(backup): support cross-platform background database
backups
---
Cargo.lock | 52 +-
README.md | 4 +
README.zh-CN.md | 2 +
agents/drivers/duckdb/Cargo.lock | 34 +-
.../backup/DatabaseBackupConfigFields.vue | 5 +-
.../ScheduledDatabaseBackupSettings.vue | 160 ++++-
.../ScheduledDatabaseBackupSettings.spec.ts | 85 ++-
.../editor/EditorSettingsDialog.vue | 4 +-
.../src/components/layout/AppDialogs.vue | 8 +-
.../SqlFileExecutionDialog.retry.spec.ts | 66 +-
.../sql-file/SqlFileExecutionDialog.vue | 21 +-
.../useDialogSources.sqlFile.spec.ts | 54 ++
.../useScheduledDatabaseBackups.spec.ts | 424 ++++-------
.../src/composables/useDialogSources.ts | 9 +-
.../useScheduledDatabaseBackups.ts | 679 ++++--------------
apps/desktop/src/i18n/locales/az.ts | 8 +
apps/desktop/src/i18n/locales/en.ts | 8 +
apps/desktop/src/i18n/locales/es.ts | 8 +
apps/desktop/src/i18n/locales/it.ts | 8 +
apps/desktop/src/i18n/locales/ja.ts | 8 +
apps/desktop/src/i18n/locales/ko.ts | 8 +
apps/desktop/src/i18n/locales/pt-BR.ts | 8 +
apps/desktop/src/i18n/locales/tr.ts | 8 +
apps/desktop/src/i18n/locales/zh-CN.ts | 8 +
apps/desktop/src/i18n/locales/zh-TW.ts | 8 +
.../backup/scheduledDatabaseBackup.spec.ts | 9 +
.../__tests__/databaseBackupRestore.spec.ts | 28 +
apps/desktop/src/lib/backend/api.ts | 5 +
apps/desktop/src/lib/backend/http.ts | 24 +
apps/desktop/src/lib/backend/tauri.ts | 20 +
.../lib/backup/backgroundDatabaseBackup.ts | 25 +
.../src/lib/backup/scheduledDatabaseBackup.ts | 25 +-
apps/desktop/src/stores/connectionStore.ts | 3 +-
crates/dbx-core/Cargo.toml | 2 +
crates/dbx-core/src/data/database_export.rs | 74 ++
crates/dbx-core/src/lib.rs | 1 +
.../dbx-core/src/scheduled_backup/engine.rs | 437 +++++++++++
crates/dbx-core/src/scheduled_backup/mod.rs | 24 +
.../dbx-core/src/scheduled_backup/models.rs | 297 ++++++++
.../dbx-core/src/scheduled_backup/service.rs | 212 ++++++
crates/dbx-core/src/scheduled_backup/store.rs | 484 +++++++++++++
crates/dbx-core/src/scheduled_backup/tests.rs | 642 +++++++++++++++++
crates/dbx-web/src/main.rs | 35 +-
crates/dbx-web/src/routes/mod.rs | 1 +
crates/dbx-web/src/routes/mq.rs | 1 +
crates/dbx-web/src/routes/scheduled_backup.rs | 48 ++
crates/dbx-web/src/routes/sql_file.rs | 356 +++++++--
crates/dbx-web/src/state.rs | 2 +
deploy/Dockerfile | 1 +
deploy/docker-compose.release.yml | 8 +-
deploy/docker-compose.yml | 4 +
docs/background-database-backups.md | 130 ++++
.../app-tests/scheduledDatabaseBackup.test.ts | 21 +-
src-tauri/src/background_backup.rs | 515 +++++++++++++
src-tauri/src/lib.rs | 33 +-
src-tauri/src/main.rs | 3 +
56 files changed, 4195 insertions(+), 962 deletions(-)
create mode 100644 apps/desktop/src/composables/__tests__/useDialogSources.sqlFile.spec.ts
create mode 100644 apps/desktop/src/lib/backend/__tests__/databaseBackupRestore.spec.ts
create mode 100644 apps/desktop/src/lib/backup/backgroundDatabaseBackup.ts
create mode 100644 crates/dbx-core/src/scheduled_backup/engine.rs
create mode 100644 crates/dbx-core/src/scheduled_backup/mod.rs
create mode 100644 crates/dbx-core/src/scheduled_backup/models.rs
create mode 100644 crates/dbx-core/src/scheduled_backup/service.rs
create mode 100644 crates/dbx-core/src/scheduled_backup/store.rs
create mode 100644 crates/dbx-core/src/scheduled_backup/tests.rs
create mode 100644 crates/dbx-web/src/routes/scheduled_backup.rs
create mode 100644 docs/background-database-backups.md
create mode 100644 src-tauri/src/background_backup.rs
diff --git a/Cargo.lock b/Cargo.lock
index 3d3ad96b84..0477374713 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1405,6 +1405,16 @@ dependencies = [
"windows-link 0.2.1",
]
+[[package]]
+name = "chrono-tz"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
+dependencies = [
+ "chrono",
+ "phf 0.12.1",
+]
+
[[package]]
name = "cipher"
version = "0.4.4"
@@ -1846,7 +1856,7 @@ dependencies = [
"cssparser-macros",
"dtoa-short",
"itoa",
- "phf",
+ "phf 0.13.1",
"smallvec",
]
@@ -2144,6 +2154,7 @@ dependencies = [
"calamine",
"cbc 0.1.2",
"chrono",
+ "chrono-tz",
"crc",
"csv",
"dbx-ai-provider",
@@ -2157,6 +2168,7 @@ dependencies = [
"encoding_rs",
"flate2",
"font-kit",
+ "fs2",
"futures",
"jsonwebtoken",
"keyring",
@@ -6416,6 +6428,15 @@ dependencies = [
"rustc_version",
]
+[[package]]
+name = "phf"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
+dependencies = [
+ "phf_shared 0.12.1",
+]
+
[[package]]
name = "phf"
version = "0.13.1"
@@ -6423,7 +6444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros",
- "phf_shared",
+ "phf_shared 0.13.1",
"serde",
]
@@ -6434,7 +6455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator",
- "phf_shared",
+ "phf_shared 0.13.1",
]
[[package]]
@@ -6444,7 +6465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
- "phf_shared",
+ "phf_shared 0.13.1",
]
[[package]]
@@ -6454,12 +6475,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
dependencies = [
"phf_generator",
- "phf_shared",
+ "phf_shared 0.13.1",
"proc-macro2",
"quote",
"syn 2.0.119",
]
+[[package]]
+name = "phf_shared"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
+dependencies = [
+ "siphasher",
+]
+
[[package]]
name = "phf_shared"
version = "0.13.1"
@@ -8113,7 +8143,7 @@ dependencies = [
"derive_more",
"log",
"new_debug_unreachable",
- "phf",
+ "phf 0.13.1",
"phf_codegen",
"precomputed-hash",
"rustc-hash",
@@ -8768,7 +8798,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
- "phf_shared",
+ "phf_shared 0.13.1",
"precomputed-hash",
]
@@ -8779,7 +8809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator",
- "phf_shared",
+ "phf_shared 0.13.1",
"proc-macro2",
"quote",
]
@@ -9427,7 +9457,7 @@ dependencies = [
"json-patch",
"log",
"memchr",
- "phf",
+ "phf 0.13.1",
"plist",
"proc-macro2",
"quote",
@@ -9730,7 +9760,7 @@ dependencies = [
"log",
"parking_lot",
"percent-encoding",
- "phf",
+ "phf 0.13.1",
"pin-project-lite",
"postgres-protocol",
"postgres-types",
@@ -10679,7 +10709,7 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297"
dependencies = [
- "phf",
+ "phf 0.13.1",
"phf_codegen",
"string_cache",
"string_cache_codegen",
diff --git a/README.md b/README.md
index 058b24d65e..f2238d458c 100644
--- a/README.md
+++ b/README.md
@@ -342,6 +342,10 @@ Updates then arrive through the regular `flatpak update`. See the [DBX page on F
## Self-Hosted (Docker)
+For scheduled backups after closing the desktop app or browser, see
+[Background Database Backups](docs/background-database-backups.md), including
+Windows/macOS/Linux startup and persistent container backup volumes.
+
DBX provides a web version that can be deployed via Docker. The examples use
the `latest` tag to pull the current release.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index cac1e50cd9..428f2ee4b3 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -350,6 +350,8 @@ flatpak install flatpark com.dbxio.dbx
## 自托管 (Docker)
+关闭桌面应用或浏览器后继续定时备份,参见[后台数据库备份](docs/background-database-backups.md),其中包含 Windows、macOS、Linux 自启动及容器备份卷的配置说明。
+
DBX 提供 Web 版本,可通过 Docker 部署。示例使用 `latest` 标签以拉取当前发布版本。
```bash
diff --git a/agents/drivers/duckdb/Cargo.lock b/agents/drivers/duckdb/Cargo.lock
index 4870c15b38..6d854fcad8 100644
--- a/agents/drivers/duckdb/Cargo.lock
+++ b/agents/drivers/duckdb/Cargo.lock
@@ -856,6 +856,16 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "chrono-tz"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
+dependencies = [
+ "chrono",
+ "phf 0.12.1",
+]
+
[[package]]
name = "cipher"
version = "0.4.4"
@@ -1411,6 +1421,7 @@ dependencies = [
"calamine",
"cbc 0.1.2",
"chrono",
+ "chrono-tz",
"crc",
"csv",
"dbx-ai-provider",
@@ -1423,6 +1434,7 @@ dependencies = [
"deadpool-postgres",
"encoding_rs",
"flate2",
+ "fs2",
"futures",
"jsonwebtoken",
"keyring",
@@ -4116,16 +4128,34 @@ dependencies = [
"pest",
]
+[[package]]
+name = "phf"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
+dependencies = [
+ "phf_shared 0.12.1",
+]
+
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
- "phf_shared",
+ "phf_shared 0.13.1",
"serde",
]
+[[package]]
+name = "phf_shared"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
+dependencies = [
+ "siphasher",
+]
+
[[package]]
name = "phf_shared"
version = "0.13.1"
@@ -6160,7 +6190,7 @@ dependencies = [
"log",
"parking_lot",
"percent-encoding",
- "phf",
+ "phf 0.13.1",
"pin-project-lite",
"postgres-protocol",
"postgres-types",
diff --git a/apps/desktop/src/components/backup/DatabaseBackupConfigFields.vue b/apps/desktop/src/components/backup/DatabaseBackupConfigFields.vue
index 53f3a17796..416eee9be8 100644
--- a/apps/desktop/src/components/backup/DatabaseBackupConfigFields.vue
+++ b/apps/desktop/src/components/backup/DatabaseBackupConfigFields.vue
@@ -1,4 +1,5 @@
@@ -571,7 +641,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
{{ t("databaseBackup.schedules") }}
-
{{ t("databaseBackup.runtimeRequirement") }}
+
{{ t(desktop ? "databaseBackup.backgroundLoginScope" : "databaseBackup.serverRuntime") }}
{{ t("databaseBackup.noSupportedConnections") }}
@@ -586,6 +656,15 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
+
+
+
+
{{ heartbeat ? t("databaseBackup.workerLastSeen", { time: formatDate(heartbeat) }) : t("databaseBackup.workerStarting") }}
+
{{ t("databaseBackup.serverDestination", { path: destinationRoot }) }}
+
+
+
+ {{ backupError }}
@@ -614,7 +693,7 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
-
setScheduleEnabled(schedule.id, value)" />
+ changeScheduleEnabled(schedule.id, value)" />
-
-
-
+
+
-
+
{{ t("databaseBackup.restore") }}
@@ -856,12 +935,17 @@ function restoreBackup(run: DatabaseBackupRun, file: DatabaseBackupFile) {
- {{ t("databaseBackup.nextRunPreview", { time: formatDate(nextRunPreview.toISOString()) }) }}
+
+
+
+
{{ previewError }}
+
{{ t("databaseBackup.nextRunPreview", { time: formatDate(nextRunPreview.toISOString()) }) }}
+
-
{{ t("databaseBackup.enabledHint") }}
+
{{ t("databaseBackup.savedTimeZoneHint") }}
diff --git a/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts b/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts
index 1b9bbdf69d..b145949d75 100644
--- a/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts
+++ b/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts
@@ -7,6 +7,9 @@ import ScheduledDatabaseBackupSettings from "../ScheduledDatabaseBackupSettings.
import type { DatabaseBackupRun, DatabaseBackupSchedule } from "../../../lib/backup/scheduledDatabaseBackup";
const mocks = vi.hoisted(() => ({
+ desktop: true,
+ sqlFileSource: null as any,
+ prepareDatabaseBackupRestore: vi.fn(),
connections: [] as Array<{ id: string; name: string; db_type: string }>,
schedules: [] as DatabaseBackupSchedule[],
runs: [] as DatabaseBackupRun[],
@@ -25,6 +28,7 @@ const mocks = vi.hoisted(() => ({
deleteSchedule: vi.fn(),
deleteRun: vi.fn(),
deleteRuns: vi.fn(),
+ renameRun: vi.fn(),
runSchedule: vi.fn(),
runOneShot: vi.fn(),
cancelRun: vi.fn(),
@@ -36,9 +40,15 @@ vi.mock("@/stores/connectionStore", () => ({
ensureConnected: mocks.ensureConnected,
recordConnectionLostError: mocks.recordConnectionLostError,
getConfig: (connectionId: string) => mocks.connections.find((connection) => connection.id === connectionId),
- sqlFileSource: null,
+ get sqlFileSource() {
+ return mocks.sqlFileSource;
+ },
+ set sqlFileSource(value) {
+ mocks.sqlFileSource = value;
+ },
}),
}));
+vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => mocks.desktop }));
vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
useScheduledDatabaseBackups: () => ({
@@ -48,11 +58,15 @@ vi.mock("@/composables/useScheduledDatabaseBackups", () => ({
activeRunIds: mocks.activeRunIds,
cancellingRunIds: mocks.cancellingRunIds,
activeRuns: { __v_isRef: true, value: mocks.activeRuns },
+ heartbeat: { __v_isRef: true, value: null },
+ destinationRoot: { __v_isRef: true, value: null },
+ error: { __v_isRef: true, value: "" },
saveSchedule: mocks.saveSchedule,
setScheduleEnabled: mocks.setScheduleEnabled,
deleteSchedule: mocks.deleteSchedule,
deleteRun: mocks.deleteRun,
deleteRuns: mocks.deleteRuns,
+ renameRun: mocks.renameRun,
runSchedule: mocks.runSchedule,
runOneShot: mocks.runOneShot,
cancelRun: mocks.cancelRun,
@@ -78,6 +92,9 @@ vi.mock("@tauri-apps/plugin-dialog", () => ({
}));
vi.mock("@/lib/backend/api", () => ({
+ prepareDatabaseBackupRestore: mocks.prepareDatabaseBackupRestore,
+ databaseBackupBackground: vi.fn(async () => ({ enabled: false, platform: "windows" })),
+ databaseBackupCommand: vi.fn(async () => "2026-09-13T02:00:00Z"),
listDatabases: mocks.listDatabases,
deleteDatabaseBackupFiles: vi.fn(),
revealPathInFileManager: vi.fn(),
@@ -257,6 +274,9 @@ function scheduleRunNowButton(): HTMLButtonElement {
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
+ mocks.desktop = true;
+ mocks.sqlFileSource = null;
+ mocks.prepareDatabaseBackupRestore.mockReset();
mocks.connections.splice(0);
mocks.schedules.splice(0);
mocks.runs.splice(0);
@@ -278,6 +298,7 @@ afterEach(() => {
mocks.saveSchedule.mockClear();
mocks.deleteRun.mockClear();
mocks.deleteRuns.mockClear();
+ mocks.renameRun.mockReset();
mocks.cancelRun.mockClear();
mocks.runSchedule.mockReset();
mocks.runSchedule.mockResolvedValue(null);
@@ -286,6 +307,61 @@ afterEach(() => {
});
describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
+ it.each([true, false])("restores with the correct desktop=%s source", async (desktop) => {
+ mocks.desktop = desktop;
+ const preview = { fileName: "backup.sql", filePath: "/server/tmp/sql_file/restore-token/backup.sql", preview: "SELECT 1;", sizeBytes: 9, canExecuteWithoutSelectedDatabase: true, cleanupToken: "restore-token" };
+ mocks.prepareDatabaseBackupRestore.mockResolvedValue(preview);
+ mocks.runs.push({
+ id: "restore-run",
+ scheduleName: "Nightly",
+ connectionId: "mysql-1",
+ trigger: "manual",
+ source: "scheduled",
+ status: "success",
+ startedAt: "2026-08-18T00:00:00.000Z",
+ files: [{ displayName: "backup.sql", filePath: "/backups/backup.sql", database: "app" }],
+ });
+ await mountSettings();
+ buttonWithTitle(String(i18n.global.t("databaseBackup.showFiles"))).click();
+ await flush();
+ buttonWithText(String(i18n.global.t("databaseBackup.restore"))).click();
+ await flush();
+ expect(mocks.sqlFileSource).toEqual({ connectionId: "mysql-1", database: "app", ...(desktop ? { filePath: "/backups/backup.sql" } : { preview }) });
+ if (desktop) expect(mocks.prepareDatabaseBackupRestore).not.toHaveBeenCalled();
+ else expect(mocks.prepareDatabaseBackupRestore).toHaveBeenCalledWith("restore-run", 0);
+ });
+
+ it("shows a backup display name and saves edits through the rename dialog", async () => {
+ mocks.runs.push({
+ id: "renamed-run",
+ scheduleName: "Nightly backup",
+ displayName: "Before migration",
+ connectionId: "mysql-1",
+ connectionName: "Local MySQL",
+ trigger: "manual",
+ source: "scheduled",
+ status: "success",
+ startedAt: "2026-08-18T00:00:00.000Z",
+ files: [],
+ });
+ mocks.renameRun.mockResolvedValue(true);
+ await mountSettings();
+
+ expect(document.body.textContent).toContain("Before migration");
+ buttonWithTitle(String(i18n.global.t("databaseBackup.renameBackup"))).click();
+ await flush();
+ const input = currentDialog().querySelector("input")!;
+ expect(input.value).toBe("Before migration");
+ input.value = "After migration";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ await flush();
+ saveScheduleButton().click();
+ await flush();
+
+ expect(mocks.renameRun).toHaveBeenCalledWith("renamed-run", "After migration");
+ expect(document.body.querySelector('[data-slot="dialog-content"]')).toBeNull();
+ });
+
it("reconnects once when loading databases finds a closed connection", async () => {
mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" });
mocks.listDatabases.mockRejectedValueOnce(new Error("MySQL connection failed: Input/output error: connection closed")).mockResolvedValueOnce([{ name: "app" }]);
@@ -616,6 +692,11 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
mocks.runOneShot.mockReturnValueOnce(pendingRun.promise);
await mountSettings();
+ const progress = document.body.querySelector('[role="progressbar"]');
+ expect(progress?.getAttribute("aria-valuenow")).toBe("25");
+ expect(document.body.textContent).toContain("25%");
+ expect(buttonWithTitle(String(i18n.global.t("databaseBackup.renameBackup"))).disabled).toBe(true);
+
buttonWithText(String(i18n.global.t("databaseBackup.oneShotBackup"))).click();
await flush();
buttonWithTitle(String(i18n.global.t("databaseBackup.selectDestination"))).click();
@@ -674,7 +755,7 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => {
expect(mocks.databaseExportDestinationNeedsConfirmation).toHaveBeenCalledWith("/backups");
expect(mocks.openDirectory).toHaveBeenCalledWith(expect.objectContaining({ directory: true, defaultPath: "/backups" }));
expect(mocks.recordDatabaseExportDestination).toHaveBeenCalledWith("/backups");
- expect(mocks.runSchedule).toHaveBeenCalledWith("schedule-1", "manual");
+ expect(mocks.runSchedule).toHaveBeenCalledWith("schedule-1");
});
it("does not run a legacy schedule when destination confirmation is cancelled", async () => {
diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue
index b8a197735b..0859224c9d 100644
--- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue
+++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue
@@ -2914,7 +2914,7 @@ const settingsCategoryNav = computed<{ value: SettingsCategory; label: string }[
{ value: "formatter", label: t("settings.sqlFormatterTab") },
{ value: "navigation", label: t("settings.navigationTab") },
{ value: "data", label: t("settings.dataTab") },
- ...(isWeb ? [] : [{ value: "backups" as const, label: t("databaseBackup.title") }]),
+ { value: "backups" as const, label: t("databaseBackup.title") },
{ value: "tunnels", label: t("settings.tunnelsTab") },
{ value: "shortcuts", label: t("settings.shortcutsTab") },
{ value: "snippets", label: t("settings.snippetsTab") },
@@ -8616,7 +8616,7 @@ LIMIT 100;
-
+
diff --git a/apps/desktop/src/components/layout/AppDialogs.vue b/apps/desktop/src/components/layout/AppDialogs.vue
index a01216dfa8..a59fcf9509 100644
--- a/apps/desktop/src/components/layout/AppDialogs.vue
+++ b/apps/desktop/src/components/layout/AppDialogs.vue
@@ -262,7 +262,13 @@ watch(
:prefill-table="dialogs.dataComparePrefillTable.value"
:session-id="dialogs.dataCompareSessionId.value"
/>
-
+
({
+ desktop: true,
+ releaseSqlFilePreview: vi.fn(),
+ previewWebSqlFile: vi.fn(),
addSqlFileTask: vi.fn(),
beginManualTransaction: vi.fn(),
commitManualTransaction: vi.fn(),
@@ -40,7 +43,14 @@ function passthrough(tag: string) {
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@/lib/common/utils", () => ({ uuid: mocks.uuid }));
-vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
+vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => mocks.desktop }));
+vi.mock("@/lib/backend/http", () => ({
+ previewSqlFile: mocks.previewWebSqlFile,
+ loadSqlFileUploadMaxBytes: vi.fn(async () => 200 * 1024 * 1024),
+}));
+vi.mock("@/lib/sql/httpSqlFileProgress", () => ({
+ listenSqlFileProgressById: (_id: string, handler: (progress: Record) => void) => mocks.listenSqlFileProgress(handler),
+}));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: mocks.openFileDialog }));
vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: mocks.highlight }) }));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) }));
@@ -69,6 +79,7 @@ vi.mock("@/lib/backend/api", () => ({
executeSqlFiles: mocks.executeSqlFiles,
listenSqlFileProgress: mocks.listenSqlFileProgress,
previewSqlFile: mocks.previewSqlFile,
+ releaseSqlFilePreview: mocks.releaseSqlFilePreview,
inspectSqlFileTables: mocks.inspectSqlFileTables,
}));
vi.mock("@lucide/vue", () => {
@@ -176,6 +187,8 @@ function deferred() {
beforeEach(() => {
vi.clearAllMocks();
+ mocks.desktop = true;
+ mocks.releaseSqlFilePreview.mockResolvedValue(undefined);
mocks.progressHandler = undefined;
mocks.highlighterState = reactive({ ready: false, appearance: "light" });
mocks.highlight.mockReset().mockImplementation((sql: string) => (mocks.highlighterState!.ready ? `${sql}` : sql));
@@ -245,6 +258,57 @@ afterEach(() => {
});
describe("SqlFileExecutionDialog retries", () => {
+ async function mountRestoredPreview() {
+ mocks.desktop = false;
+ const preview = {
+ fileName: "backup.sql",
+ filePath: "/server/tmp/sql_file/restore-token/backup.sql",
+ preview: "SELECT 42;",
+ sizeBytes: 10,
+ canExecuteWithoutSelectedDatabase: true,
+ cleanupToken: "restore-token",
+ };
+ root = document.createElement("div");
+ document.body.append(root);
+ app = createApp(SqlFileExecutionDialog, { open: true, prefillPreview: preview });
+ app.mount(root);
+ await vi.waitFor(() => expect(findButton("sqlFile.execute").disabled).toBe(false));
+ expect(root.textContent).toContain("SELECT 42;");
+ expect(mocks.previewSqlFile).not.toHaveBeenCalled();
+ expect(mocks.previewWebSqlFile).not.toHaveBeenCalled();
+ return preview;
+ }
+
+ it("renders a prepared Web preview without uploading a server path and releases it on unmount", async () => {
+ await mountRestoredPreview();
+ app!.unmount();
+ app = undefined;
+ await vi.waitFor(() => expect(mocks.releaseSqlFilePreview).toHaveBeenCalledWith("restore-token"));
+ });
+
+ it.each(["done", "error", "cancelled"])("executes the prepared server object and releases it after %s", async (status) => {
+ const preview = await mountRestoredPreview();
+ mocks.executeSqlFiles.mockImplementationOnce(async (request: { executionId: string }) => {
+ expect(mocks.releaseSqlFilePreview).not.toHaveBeenCalled();
+ mocks.progressHandler?.(progress(request.executionId, status, status === "error" ? { error: "restore failed" } : {}));
+ });
+ findButton("sqlFile.execute").click();
+ await vi.waitFor(() => expect(mocks.executeSqlFiles).toHaveBeenCalledWith(expect.objectContaining({ filePath: preview.filePath }), [preview.filePath]));
+ await vi.waitFor(() => expect(mocks.releaseSqlFilePreview).toHaveBeenCalledWith("restore-token"));
+ expect(mocks.previewWebSqlFile).not.toHaveBeenCalled();
+ });
+
+ it("keeps browser uploads on the File-only preview path", async () => {
+ await mountRestoredPreview();
+ const file = new File(["SELECT 2;"], "upload.sql", { type: "text/plain" });
+ mocks.previewWebSqlFile.mockResolvedValueOnce({ fileName: file.name, filePath: "/server/tmp/sql_file/upload.sql", sizeBytes: file.size, preview: "SELECT 2;", canExecuteWithoutSelectedDatabase: true });
+ const input = root!.querySelector('input[type="file"]')!;
+ Object.defineProperty(input, "files", { value: [file] });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await vi.waitFor(() => expect(mocks.previewWebSqlFile).toHaveBeenCalledWith(file));
+ expect(mocks.releaseSqlFilePreview).toHaveBeenCalledWith("restore-token");
+ });
+
it("reuses the unchanged preview across progress and failure updates while keeping controls responsive", async () => {
const onOpenChange = vi.fn();
await mountReadyDialog(onOpenChange);
diff --git a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue
index 35107112d7..d8f65deae7 100644
--- a/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue
+++ b/apps/desktop/src/components/sql-file/SqlFileExecutionDialog.vue
@@ -39,6 +39,7 @@ const props = defineProps<{
prefillConnectionId?: string;
prefillDatabase?: string;
prefillFilePath?: string;
+ prefillPreview?: SqlFilePreview;
}>();
const store = useConnectionStore();
@@ -239,6 +240,7 @@ watch(canUseManualTransaction, (supported) => {
});
onBeforeUnmount(() => {
disposed = true;
+ if (!running.value) void releaseManagedPreviews();
if (running.value && manualTransaction.value) {
cancelRequested.value = true;
if (executionStarted.value) void cancelSqlFileExecution(executionId.value).catch(() => {});
@@ -469,6 +471,7 @@ async function previewSelectedSqlFile(fileOrPath: string | File) {
async function loadPreviews(filesOrPaths: Array) {
if (executionLocked.value) return;
+ await releaseManagedPreviews();
loadingPreview.value = true;
previews.value = [];
resetExecution();
@@ -485,6 +488,18 @@ async function loadPreviews(filesOrPaths: Array) {
}
}
+async function releaseManagedPreviews() {
+ const tokens = previews.value.flatMap((preview) => (preview.cleanupToken ? [preview.cleanupToken] : []));
+ if (!tokens.length) return;
+ previews.value = previews.value.filter((preview) => !preview.cleanupToken);
+ try {
+ const { releaseSqlFilePreview } = await import("@/lib/backend/api");
+ await Promise.all(tokens.map((token) => releaseSqlFilePreview(token)));
+ } catch (error: any) {
+ toast(error?.message || String(error), 5000);
+ }
+}
+
async function selectFile() {
if (executionLocked.value) return;
if (!isTauriRuntime()) {
@@ -721,6 +736,7 @@ async function startExecution() {
running.value = false;
cancelling.value = false;
executionStarted.value = false;
+ await releaseManagedPreviews();
}
}
@@ -763,6 +779,7 @@ watch(
open,
(value) => {
if (!value) {
+ if (!running.value) void releaseManagedPreviews();
if (manualTransaction.value && running.value) void cancelExecution();
else if (txnSessionId.value) void finishTransaction(false);
return;
@@ -774,7 +791,9 @@ watch(
}
// When opened from the SQL Files panel with a pre-selected file, load its
// preview automatically so the user can review statements before running.
- if (props.prefillFilePath) {
+ if (props.prefillPreview) {
+ previews.value = [props.prefillPreview];
+ } else if (props.prefillFilePath) {
void loadPreviews([props.prefillFilePath]);
}
},
diff --git a/apps/desktop/src/composables/__tests__/useDialogSources.sqlFile.spec.ts b/apps/desktop/src/composables/__tests__/useDialogSources.sqlFile.spec.ts
new file mode 100644
index 0000000000..56cb7941c6
--- /dev/null
+++ b/apps/desktop/src/composables/__tests__/useDialogSources.sqlFile.spec.ts
@@ -0,0 +1,54 @@
+// @vitest-environment happy-dom
+
+import { createApp, defineComponent, h, nextTick, type App } from "vue";
+import { afterEach, expect, it, vi } from "vitest";
+import type { SqlFilePreview } from "@/lib/backend/api";
+
+const mocks = vi.hoisted(() => ({
+ store: {
+ connections: [],
+ sqlFileSource: null as { connectionId: string; database: string; filePath?: string; preview?: SqlFilePreview } | null,
+ },
+}));
+vi.mock("@/stores/connectionStore", async () => {
+ const { reactive } = await import("vue");
+ mocks.store = reactive(mocks.store);
+ return { useConnectionStore: () => mocks.store };
+});
+vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
+vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
+
+import { useDialogSources } from "@/composables/useDialogSources";
+
+let app: App | undefined;
+afterEach(() => {
+ app?.unmount();
+ document.body.innerHTML = "";
+});
+
+it("hands prepared Web previews to the dialog, clears them on close, and preserves desktop paths", async () => {
+ let dialogs!: ReturnType;
+ app = createApp(
+ defineComponent({
+ setup() {
+ dialogs = useDialogSources();
+ return () => h("div");
+ },
+ }),
+ );
+ app.mount(document.body.appendChild(document.createElement("div")));
+ const preview = { fileName: "backup.sql", filePath: "/server/tmp/sql_file/restore-token/backup.sql", preview: "SELECT 1;", sizeBytes: 9, canExecuteWithoutSelectedDatabase: true, cleanupToken: "restore-token" };
+ mocks.store.sqlFileSource = { connectionId: "mysql", database: "app", preview };
+ await nextTick();
+ expect(dialogs.showSqlFileDialog.value).toBe(true);
+ expect(dialogs.sqlFilePrefillPreview.value).toEqual(preview);
+ expect(dialogs.sqlFilePrefillFilePath.value).toBe("");
+ expect(mocks.store.sqlFileSource).toBeNull();
+ dialogs.showSqlFileDialog.value = false;
+ await nextTick();
+ expect(dialogs.sqlFilePrefillPreview.value).toBeUndefined();
+ mocks.store.sqlFileSource = { connectionId: "mysql", database: "app", filePath: "/local/backup.sql" };
+ await nextTick();
+ expect(dialogs.sqlFilePrefillFilePath.value).toBe("/local/backup.sql");
+ expect(dialogs.sqlFilePrefillPreview.value).toBeUndefined();
+});
diff --git a/apps/desktop/src/composables/__tests__/useScheduledDatabaseBackups.spec.ts b/apps/desktop/src/composables/__tests__/useScheduledDatabaseBackups.spec.ts
index 90320a3446..8ed00aabb7 100644
--- a/apps/desktop/src/composables/__tests__/useScheduledDatabaseBackups.spec.ts
+++ b/apps/desktop/src/composables/__tests__/useScheduledDatabaseBackups.spec.ts
@@ -1,310 +1,190 @@
-import { describe, expect, it, vi } from "vitest";
-import type { DatabaseBackupExecutionConfig, DatabaseBackupRun } from "../../lib/backup/scheduledDatabaseBackup";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { DatabaseBackupRun, DatabaseBackupSchedule } from "../../lib/backup/scheduledDatabaseBackup";
+import type { DatabaseBackupSnapshot } from "../../lib/backup/backgroundDatabaseBackup";
const mocks = vi.hoisted(() => ({
- connection: { id: "mysql-1", name: "Local MySQL", db_type: "mysql" },
- listDatabases: vi.fn(async () => [{ name: "app" }]),
- beginSnapshot: vi.fn(async () => ({ sessionId: "snapshot-1" })),
- rollbackSnapshot: vi.fn(async () => {}),
- deleteFiles: vi.fn(async () => 0),
- cancelExport: vi.fn(async () => {}),
- clearExportCancellation: vi.fn(async () => {}),
- exportDatabase: vi.fn(),
- runDatabaseExport: vi.fn(),
+ command: vi.fn(),
+ legacySchedules: vi.fn(() => []),
+ legacyRuns: vi.fn(() => []),
+ desktop: true,
addTask: vi.fn(),
- registerCancel: vi.fn(),
- unregisterCancel: vi.fn(),
updateTask: vi.fn(),
- markTaskCancelling: vi.fn(),
- restoreTaskRunning: vi.fn(),
- nextId: 0,
-}));
-
-vi.mock("@/lib/backend/api", () => ({
- listDatabases: mocks.listDatabases,
- beginDatabaseBackupSnapshot: mocks.beginSnapshot,
- rollbackManualTransaction: mocks.rollbackSnapshot,
- deleteDatabaseBackupFiles: mocks.deleteFiles,
- cancelDatabaseExport: mocks.cancelExport,
- clearDatabaseExportCancellation: mocks.clearExportCancellation,
-}));
-
-vi.mock("@/lib/export/databaseExport", () => ({
- buildAllDatabaseExportPlan: () => [{ database: "app", schema: "app", fileStem: "app", displayName: "app" }],
- generateDatabaseExportId: () => `run-${++mocks.nextId}`,
- runDatabaseExportUntilTerminal: mocks.runDatabaseExport,
-}));
-
-vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
-vi.mock("@/stores/connectionStore", () => ({
- useConnectionStore: () => ({
- getConfig: () => mocks.connection,
- ensureConnected: vi.fn(async () => {}),
- initFromDisk: vi.fn(async () => {}),
- }),
+ cancelTask: vi.fn(),
+ register: vi.fn(),
+ unregister: vi.fn(),
}));
+vi.mock("vue", async () => ({ ...(await vi.importActual("vue")), onMounted: vi.fn(), onUnmounted: vi.fn() }));
+vi.mock("@/lib/backend/api", () => ({ databaseBackupCommand: mocks.command }));
+vi.mock("@/lib/backend/debugLog", () => ({ appendDebugLog: vi.fn() }));
+vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => mocks.desktop }));
+vi.mock("@/lib/backup/scheduledDatabaseBackup", () => ({ readDatabaseBackupSchedules: mocks.legacySchedules, readDatabaseBackupRuns: mocks.legacyRuns }));
vi.mock("@/composables/useExportTracker", () => ({
useExportTracker: () => ({
addDatabaseExportTask: mocks.addTask,
- registerTaskCancelHandler: mocks.registerCancel,
- unregisterTaskCancelHandler: mocks.unregisterCancel,
updateDatabaseExportTask: mocks.updateTask,
- markDatabaseExportTaskCancelling: mocks.markTaskCancelling,
- restoreDatabaseExportTaskRunning: mocks.restoreTaskRunning,
+ markDatabaseExportTaskCancelling: mocks.cancelTask,
+ registerTaskCancelHandler: mocks.register,
+ unregisterTaskCancelHandler: mocks.unregister,
}),
}));
-import { useScheduledDatabaseBackups } from "../useScheduledDatabaseBackups";
+let snapshot: DatabaseBackupSnapshot;
+function run(status: DatabaseBackupRun["status"] = "running"): DatabaseBackupRun {
+ return { id: "run-1", scheduleId: "schedule-1", scheduleName: "Daily", connectionId: "mysql", connectionName: "Local", destinationDirectory: "/backups", source: "scheduled", trigger: "manual", status, startedAt: "2026-09-12T00:00:00Z", files: [], progressPercent: 45 };
+}
+async function create() {
+ return (await import("../useScheduledDatabaseBackups")).useScheduledDatabaseBackups();
+}
-const config: DatabaseBackupExecutionConfig = {
- connectionId: "mysql-1",
- databases: ["app"],
- tableFilterMode: "all",
- tablePatterns: [],
- destinationDirectory: "/backups",
- includeStructure: true,
- includeData: true,
- includeObjects: true,
- dropTableIfExists: false,
- outputCompression: "none",
-};
+beforeEach(() => {
+ vi.resetModules();
+ vi.clearAllMocks();
+ mocks.desktop = true;
+ snapshot = { schedules: [], runs: [], migrated: true, heartbeat: "2026-09-12T00:00:00Z", destinationRoot: null };
+ mocks.command.mockImplementation(async (command) => (command.action === "snapshot" ? structuredClone(snapshot) : null));
+});
-describe("useScheduledDatabaseBackups one-shot execution", () => {
- it("deletes multiple completed runs and all of their backup files", async () => {
- const backup = useScheduledDatabaseBackups();
- const firstRun = {
- id: "delete-many-first",
- scheduleName: "First backup",
- connectionId: "mysql-1",
- connectionName: "Local MySQL",
- destinationDirectory: "/backups",
- trigger: "scheduled",
- source: "scheduled",
- status: "success",
- startedAt: "2026-09-08T01:00:00.000Z",
- files: [{ displayName: "first.sql", filePath: "/backups/first.sql" }],
- } satisfies DatabaseBackupRun;
- const secondRun = {
- id: "delete-many-second",
- scheduleName: "Second backup",
- connectionId: "mysql-1",
- connectionName: "Local MySQL",
- destinationDirectory: "/backups",
- trigger: "scheduled",
- source: "scheduled",
- status: "failed",
- startedAt: "2026-09-08T02:00:00.000Z",
- files: [{ displayName: "second.sql", filePath: "/backups/second.sql" }],
- } satisfies DatabaseBackupRun;
- backup.runs.value.push(firstRun, secondRun);
- mocks.deleteFiles.mockClear();
-
- try {
- await backup.deleteRuns([firstRun.id, secondRun.id]);
-
- expect(mocks.deleteFiles).toHaveBeenCalledWith(["/backups/first.sql", "/backups/second.sql"], ["/backups"]);
- expect(backup.runs.value).not.toContainEqual(expect.objectContaining({ id: firstRun.id }));
- expect(backup.runs.value).not.toContainEqual(expect.objectContaining({ id: secondRun.id }));
- } finally {
- backup.runs.value = backup.runs.value.filter((run) => run.id !== firstRun.id && run.id !== secondRun.id);
- }
+describe("backend-owned scheduled database backups", () => {
+ it("reconnects to running backups without starting another export", async () => {
+ snapshot.runs = [run()];
+ const backups = await create();
+ await backups.processDueSchedules();
+ await backups.processDueSchedules();
+ expect(backups.activeRunIds.has("run-1")).toBe(true);
+ expect(backups.activeScheduleIds.has("schedule-1")).toBe(true);
+ expect(mocks.addTask).toHaveBeenCalledTimes(1);
+ expect(mocks.command.mock.calls.every(([c]) => c.action === "snapshot")).toBe(true);
+ expect(mocks.updateTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "Running", overallPercent: 45 }));
});
- it("keeps a shared backup path when deleting one of its history records", async () => {
- const backup = useScheduledDatabaseBackups();
- const sharedPath = "/backups/before-migration__app.sql";
- const firstRun = {
- id: "shared-path-first",
- scheduleName: "First backup",
- connectionId: "mysql-1",
- connectionName: "Local MySQL",
- trigger: "scheduled",
- source: "scheduled",
- status: "success",
- startedAt: "2026-09-08T01:00:00.000Z",
- files: [{ displayName: "first.sql", filePath: sharedPath }],
- } satisfies DatabaseBackupRun;
- const secondRun = { ...firstRun, id: "shared-path-second", startedAt: "2026-09-08T02:00:00.000Z" } satisfies DatabaseBackupRun;
- backup.runs.value.push(firstRun, secondRun);
- mocks.deleteFiles.mockClear();
-
- try {
- await backup.deleteRun(firstRun.id);
-
- expect(mocks.deleteFiles).not.toHaveBeenCalled();
- expect(backup.runs.value).not.toContainEqual(expect.objectContaining({ id: firstRun.id }));
- expect(backup.runs.value).toContainEqual(expect.objectContaining({ id: secondRun.id }));
- } finally {
- backup.runs.value = backup.runs.value.filter((run) => run.id !== firstRun.id && run.id !== secondRun.id);
- }
+ it("reflects completion from a worker that runs independently of the UI", async () => {
+ snapshot.runs = [run()];
+ const backups = await create();
+ await backups.processDueSchedules();
+ snapshot.runs[0] = { ...run("success"), progressPercent: 100 };
+ await backups.processDueSchedules();
+ expect(backups.activeRunIds.size).toBe(0);
+ expect(backups.activeScheduleIds.size).toBe(0);
+ expect(mocks.updateTask).toHaveBeenLastCalledWith("run-1", expect.objectContaining({ status: "Done", overallPercent: 100 }));
});
- it("executes one-shot backups through the shared exporter without a schedule", async () => {
- mocks.runDatabaseExport.mockImplementationOnce(async (_request: unknown, onProgress: (progress: unknown) => void) => {
- onProgress({ status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" });
- return { status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" };
- });
-
- const backup = useScheduledDatabaseBackups();
- const scheduleCountBefore = backup.schedules.value.length;
- const run = await backup.runOneShot(config, "One-time backup");
-
- expect(run).toEqual(expect.objectContaining({ status: "success", source: "one-shot", trigger: "manual", scheduleId: undefined }));
- expect(run).not.toHaveProperty("nextRunAt");
- expect(backup.schedules.value).toHaveLength(scheduleCountBefore);
- expect(mocks.runDatabaseExport).toHaveBeenCalledWith(expect.objectContaining({ preventOverwrite: true }), expect.any(Function));
- expect(mocks.addTask).toHaveBeenCalledWith(expect.any(String), "One-time backup", "/backups", "manual");
+ it("waits for durable cancellation acceptance before showing cancellation", async () => {
+ snapshot.runs = [run()];
+ const backups = await create();
+ await backups.processDueSchedules();
+ mocks.command.mockRejectedValueOnce(new Error("offline"));
+ await expect(backups.cancelRun("run-1")).rejects.toThrow("offline");
+ expect(backups.cancellingRunIds.size).toBe(0);
+ mocks.command.mockResolvedValueOnce(true);
+ expect(await backups.cancelRun("run-1")).toBe(true);
+ expect(backups.cancellingRunIds.has("run-1")).toBe(true);
+ expect(mocks.command).toHaveBeenLastCalledWith({ action: "cancel", id: "run-1" });
});
- it("rejects a repeated one-shot when its custom file name resolves to an existing path", async () => {
- mocks.runDatabaseExport.mockImplementation(async (_request: unknown, onProgress: (progress: unknown) => void) => {
- const progress = { status: "Done", objectIndex: 1, totalObjects: 1, currentObject: "app" };
- onProgress(progress);
- return progress;
- });
- const backup = useScheduledDatabaseBackups();
- const customConfig = { ...config, fileNamePattern: "before-migration" };
- let runIds: string[] = [];
-
- try {
- const firstRun = await backup.runOneShot(customConfig, "One-time backup");
- runIds = [firstRun?.id].filter((id): id is string => Boolean(id));
- const exportCallCount = mocks.runDatabaseExport.mock.calls.length;
- const secondRun = await backup.runOneShot(customConfig, "One-time backup");
- if (secondRun?.id) runIds.push(secondRun.id);
-
- expect(firstRun).toEqual(expect.objectContaining({ status: "success" }));
- expect(secondRun).toEqual(expect.objectContaining({ status: "failed", files: [] }));
- expect(secondRun?.error).toContain("already used by another run");
- expect(mocks.runDatabaseExport).toHaveBeenCalledTimes(exportCallCount);
- } finally {
- const ids = new Set(runIds);
- backup.runs.value = backup.runs.value.filter((run) => !ids.has(run.id));
- }
+ it("deletes by server record IDs, never by client supplied file paths", async () => {
+ snapshot.runs = [run("success")];
+ const backups = await create();
+ await backups.processDueSchedules();
+ await backups.deleteRuns(["run-1"]);
+ expect(mocks.command).toHaveBeenCalledWith({ action: "deleteRuns", ids: ["run-1"] });
});
- it("does not delete a pre-existing file when the exporter rejects overwrite", async () => {
- mocks.runDatabaseExport.mockRejectedValueOnce(new Error("Backup file already exists: /backups/before-migration__app.sql"));
- const backup = useScheduledDatabaseBackups();
- const result = await backup.runOneShot({ ...config, fileNamePattern: "before-migration" }, "One-time backup");
-
- expect(result).toEqual(expect.objectContaining({ status: "failed", files: [] }));
- expect(mocks.deleteFiles).not.toHaveBeenCalled();
+ it("preserves optimistic concurrency timestamps and saved time zones", async () => {
+ const schedule = { id: "schedule-1", name: "Daily", timeZone: "Asia/Tokyo", updatedAt: "2026-09-01T00:00:00Z" } as DatabaseBackupSchedule;
+ const backups = await create();
+ await backups.saveSchedule(schedule);
+ expect(mocks.command).toHaveBeenCalledWith({ action: "save", schedule });
});
- it("prevents a second one-shot while one is already running", async () => {
- const backup = useScheduledDatabaseBackups();
- const activeRun = {
- id: "active-one-shot",
- scheduleName: "One-time backup",
- connectionId: "mysql-1",
- connectionName: "Local MySQL",
- trigger: "manual",
- source: "one-shot",
- status: "running",
- startedAt: new Date().toISOString(),
- files: [],
- } satisfies DatabaseBackupRun;
- backup.runs.value.push(activeRun);
- backup.activeRunIds.add(activeRun.id);
- const exportCallCount = mocks.runDatabaseExport.mock.calls.length;
-
- try {
- expect(await backup.runOneShot(config, "One-time backup")).toBeNull();
- expect(mocks.runDatabaseExport).toHaveBeenCalledTimes(exportCallCount);
- } finally {
- backup.activeRunIds.delete(activeRun.id);
- backup.runs.value = backup.runs.value.filter((run: DatabaseBackupRun) => run.id !== activeRun.id);
- }
+ it("does not overwrite local state after a failed save", async () => {
+ snapshot.schedules = [{ id: "schedule-1", name: "Original" } as DatabaseBackupSchedule];
+ const backups = await create();
+ await backups.processDueSchedules();
+ mocks.command.mockRejectedValueOnce(new Error("schedule changed"));
+ await expect(backups.saveSchedule({ ...snapshot.schedules[0], name: "New" })).rejects.toThrow("schedule changed");
+ expect(backups.schedules.value[0].name).toBe("Original");
});
- it("cancels an active one-shot through the shared export cancellation path", async () => {
- mocks.cancelExport.mockClear();
- mocks.deleteFiles.mockClear();
- let resolveExport!: (progress: { status: "Cancelled"; objectIndex: number; totalObjects: number; currentObject: string }) => void;
- mocks.runDatabaseExport.mockImplementationOnce(
- (_request: unknown, onProgress: (progress: unknown) => void) =>
- new Promise((resolve) => {
- resolveExport = (progress) => {
- onProgress(progress);
- resolve(progress);
- };
- }),
- );
-
- const backup = useScheduledDatabaseBackups();
- const pendingRun = backup.runOneShot(config, "One-time backup");
- await new Promise((resolve) => setTimeout(resolve, 0));
- const activeRunId = [...backup.activeRunIds][0];
- expect(activeRunId).toBeTruthy();
-
- await backup.cancelRun(activeRunId!);
- expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
- expect(mocks.cancelExport).toHaveBeenCalledWith(`${activeRunId}-1`);
- expect(mocks.markTaskCancelling).toHaveBeenCalledWith(activeRunId);
- expect(backup.cancellingRunIds.has(activeRunId!)).toBe(true);
- await backup.cancelRun(activeRunId!);
- expect(mocks.cancelExport).toHaveBeenCalledTimes(2);
- resolveExport({ status: "Cancelled", objectIndex: 0, totalObjects: 1, currentObject: "app" });
-
- const finishedRun = await pendingRun;
- expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", source: "one-shot", files: [] }));
- expect(mocks.deleteFiles).toHaveBeenCalledWith([expect.stringContaining(activeRunId!)], ["/backups"]);
- expect(backup.runs.value.find((run) => run.id === activeRunId)).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
- expect(backup.cancellingRunIds.has(activeRunId!)).toBe(false);
- });
-
- it("stops after cancelling during database discovery without creating a snapshot", async () => {
- mocks.beginSnapshot.mockClear();
- mocks.cancelExport.mockClear();
- let resolveDatabases!: (databases: Array<{ name: string }>) => void;
- mocks.listDatabases.mockImplementationOnce(
+ it("reloads after a save even when an older poll finishes later", async () => {
+ const backups = await create();
+ const oldSnapshot = structuredClone(snapshot);
+ let release!: (value: DatabaseBackupSnapshot) => void;
+ mocks.command.mockImplementationOnce(
() =>
- new Promise((resolve) => {
- resolveDatabases = resolve;
+ new Promise((resolve) => {
+ release = resolve;
}),
);
-
- const backup = useScheduledDatabaseBackups();
- const pendingRun = backup.runOneShot(config, "One-time backup");
- await new Promise((resolve) => setTimeout(resolve, 0));
- const activeRunId = [...backup.activeRunIds][0];
- expect(activeRunId).toBeTruthy();
-
- await backup.cancelRun(activeRunId!);
- expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
- resolveDatabases([{ name: "app" }]);
-
- const finishedRun = await pendingRun;
- expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
- expect(mocks.beginSnapshot).not.toHaveBeenCalled();
+ const polling = backups.processDueSchedules();
+ const schedule = { id: "schedule-1", name: "New", timeZone: "Asia/Shanghai" } as DatabaseBackupSchedule;
+ mocks.command.mockImplementation(async (command) => {
+ if (command.action === "save") {
+ snapshot.schedules = [schedule];
+ return schedule;
+ }
+ return structuredClone(snapshot);
+ });
+ const saving = backups.saveSchedule(schedule);
+ release(oldSnapshot);
+ await polling;
+ await saving;
+ expect(backups.schedules.value).toEqual([schedule]);
+ expect(mocks.command.mock.calls.filter(([command]) => command.action === "snapshot")).toHaveLength(2);
});
- it("signals the backup run while snapshot creation is pending", async () => {
- mocks.beginSnapshot.mockClear();
- mocks.cancelExport.mockClear();
- let resolveSnapshot!: (snapshot: { sessionId: string }) => void;
- mocks.beginSnapshot.mockImplementationOnce(
+ it("does not lose a newly queued run to a stale in-flight snapshot", async () => {
+ const backups = await create();
+ const oldSnapshot = structuredClone(snapshot);
+ let release!: (value: DatabaseBackupSnapshot) => void;
+ mocks.command.mockImplementationOnce(
() =>
- new Promise((resolve) => {
- resolveSnapshot = resolve;
+ new Promise((resolve) => {
+ release = resolve;
}),
);
+ const polling = backups.processDueSchedules();
+ mocks.command.mockImplementation(async (command) => {
+ if (command.action === "run") {
+ snapshot.runs = [run("success")];
+ return run();
+ }
+ return structuredClone(snapshot);
+ });
+ const running = backups.runSchedule("schedule-1");
+ release(oldSnapshot);
+ await polling;
+ expect(await running).toEqual(run("success"));
+ });
- const backup = useScheduledDatabaseBackups();
- const pendingRun = backup.runOneShot(config, "One-time backup");
- await new Promise((resolve) => setTimeout(resolve, 0));
- const activeRunId = [...backup.activeRunIds][0];
- expect(activeRunId).toBeTruthy();
- expect(mocks.beginSnapshot).toHaveBeenCalledWith("mysql-1", "app", activeRunId);
+ it("marks migration only after the backend accepts it", async () => {
+ snapshot.migrated = false;
+ const backups = await create();
+ mocks.command.mockImplementation(async (command) => {
+ if (command.action === "migrate") throw new Error("database locked");
+ return structuredClone(snapshot);
+ });
+ await expect(backups.processDueSchedules()).rejects.toThrow("database locked");
+ expect(mocks.legacySchedules).toHaveBeenCalledTimes(1);
+ expect(backups.error.value).toBe("database locked");
+ mocks.command.mockImplementation(async (command) => {
+ if (command.action === "migrate") {
+ snapshot.migrated = true;
+ return null;
+ }
+ return structuredClone(snapshot);
+ });
+ await backups.processDueSchedules();
+ await backups.processDueSchedules();
+ expect(mocks.legacySchedules).toHaveBeenCalledTimes(2);
+ });
- await backup.cancelRun(activeRunId!);
- expect(mocks.cancelExport).toHaveBeenCalledWith(activeRunId);
- resolveSnapshot({ sessionId: "snapshot-pending" });
-
- const finishedRun = await pendingRun;
- expect(finishedRun).toEqual(expect.objectContaining({ status: "cancelled", files: [] }));
- expect(mocks.rollbackSnapshot).toHaveBeenCalledWith("snapshot-pending");
- expect(mocks.clearExportCancellation).toHaveBeenCalledWith(activeRunId);
+ it("never migrates browser-local paths into a server", async () => {
+ mocks.desktop = false;
+ snapshot.migrated = false;
+ const backups = await create();
+ await backups.processDueSchedules();
+ expect(mocks.legacySchedules).not.toHaveBeenCalled();
+ expect(mocks.legacyRuns).not.toHaveBeenCalled();
+ expect(mocks.command).toHaveBeenCalledWith({ action: "migrate", migration: { schedules: [], runs: [] } });
});
});
diff --git a/apps/desktop/src/composables/useDialogSources.ts b/apps/desktop/src/composables/useDialogSources.ts
index 26da568647..f50eb96716 100644
--- a/apps/desktop/src/composables/useDialogSources.ts
+++ b/apps/desktop/src/composables/useDialogSources.ts
@@ -1,4 +1,5 @@
import { ref, watch } from "vue";
+import type { SqlFilePreview } from "@/lib/backend/api";
import { useI18n } from "vue-i18n";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
@@ -61,6 +62,7 @@ const dataCompareSessionId = ref(null);
const sqlFilePrefillConnectionId = ref("");
const sqlFilePrefillDatabase = ref("");
const sqlFilePrefillFilePath = ref("");
+const sqlFilePrefillPreview = ref();
const diagramPrefillConnectionId = ref("");
const diagramPrefillDatabase = ref("");
const diagramPrefillSchema = ref("");
@@ -195,6 +197,7 @@ export function useDialogSources() {
sqlFilePrefillConnectionId.value = v.connectionId;
sqlFilePrefillDatabase.value = v.database;
sqlFilePrefillFilePath.value = v.filePath ?? "";
+ sqlFilePrefillPreview.value = v.preview;
showSqlFileDialog.value = true;
connectionStore.sqlFileSource = null;
}
@@ -207,7 +210,10 @@ export function useDialogSources() {
// when stale (they only preselect dropdowns), but a stale path triggers an
// async file read + preview render — a visible side effect.
watch(showSqlFileDialog, (open) => {
- if (!open) sqlFilePrefillFilePath.value = "";
+ if (!open) {
+ sqlFilePrefillFilePath.value = "";
+ sqlFilePrefillPreview.value = undefined;
+ }
});
watch(
@@ -577,6 +583,7 @@ export function useDialogSources() {
sqlFilePrefillConnectionId,
sqlFilePrefillDatabase,
sqlFilePrefillFilePath,
+ sqlFilePrefillPreview,
diagramPrefillConnectionId,
diagramPrefillDatabase,
diagramPrefillSchema,
diff --git a/apps/desktop/src/composables/useScheduledDatabaseBackups.ts b/apps/desktop/src/composables/useScheduledDatabaseBackups.ts
index ea2eeb0601..6bb4042b8e 100644
--- a/apps/desktop/src/composables/useScheduledDatabaseBackups.ts
+++ b/apps/desktop/src/composables/useScheduledDatabaseBackups.ts
@@ -2,567 +2,169 @@ import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import * as api from "@/lib/backend/api";
import { appendDebugLog } from "@/lib/backend/debugLog";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
-import { useConnectionStore } from "@/stores/connectionStore";
-import { buildAllDatabaseExportPlan, generateDatabaseExportId, runDatabaseExportUntilTerminal } from "@/lib/export/databaseExport";
-import {
- DATABASE_BACKUP_CONFIG_CHANGED_EVENT,
- DATABASE_BACKUP_RUNS_STORAGE_KEY,
- DATABASE_BACKUP_SCHEDULES_STORAGE_KEY,
- DatabaseBackupConnectionQueue,
- databaseBackupAggregateExportStatus,
- databaseBackupFilePath,
- databaseBackupRunDirectory,
- databaseBackupProgressPercent,
- databaseBackupRunsToPrune,
- databaseBackupScheduleIsDue,
- databaseBackupTableNamesAreCaseSensitive,
- nextDatabaseBackupRunAt,
- normalizeDatabaseBackupSchedule,
- readDatabaseBackupRuns,
- readDatabaseBackupSchedules,
- resolveScheduledDatabaseBackupTableScope,
- resolveScheduledDatabaseBackupTargets,
- supportsScheduledDatabaseBackup,
- toDatabaseBackupExecutionConfig,
- writeDatabaseBackupRuns,
- writeDatabaseBackupSchedules,
- type DatabaseBackupExecutionConfig,
- type DatabaseBackupFile,
- type DatabaseBackupRun,
- type DatabaseBackupRunSource,
- type DatabaseBackupRunStatus,
- type DatabaseBackupRunTrigger,
- type DatabaseBackupSchedule,
-} from "@/lib/backup/scheduledDatabaseBackup";
+import { readDatabaseBackupRuns, readDatabaseBackupSchedules, type DatabaseBackupExecutionConfig, type DatabaseBackupRun, type DatabaseBackupSchedule } from "@/lib/backup/scheduledDatabaseBackup";
+import type { DatabaseBackupSnapshot } from "@/lib/backup/backgroundDatabaseBackup";
import { useExportTracker } from "@/composables/useExportTracker";
-const SCHEDULER_INTERVAL_MS = 30_000;
-const databaseBackupConnectionQueue = new DatabaseBackupConnectionQueue();
-
-const schedules = ref(readDatabaseBackupSchedules());
-const runs = ref(readDatabaseBackupRuns());
+const schedules = ref([]);
+const runs = ref([]);
const activeScheduleIds = reactive(new Set());
const activeRunIds = reactive(new Set());
const cancellingRunIds = reactive(new Set());
-const activeExportIds = new Map();
-const activeBackupPaths = new Set();
-const cancellationRequested = new Set();
+const heartbeat = ref(null);
+const destinationRoot = ref(null);
+const error = ref("");
+const tracked = new Set();
+let refreshing: Promise | undefined;
+let subscribers = 0;
+let timer: ReturnType | undefined;
-let schedulerTimer: ReturnType | undefined;
-let schedulerRegistered = false;
-let processingDueSchedules = false;
+export function useScheduledDatabaseBackups(_options: { scheduler?: boolean } = {}) {
+ const tracker = useExportTracker();
+ const activeRuns = computed(() => runs.value.filter((run) => run.status === "running"));
-function emitConfigChanged() {
- if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent(DATABASE_BACKUP_CONFIG_CHANGED_EVENT));
-}
-
-function persistSchedules() {
- writeDatabaseBackupSchedules(schedules.value);
- emitConfigChanged();
-}
-
-function persistRuns() {
- writeDatabaseBackupRuns(runs.value);
-}
-
-function replaceRun(run: DatabaseBackupRun, persist = true) {
- runs.value = [run, ...runs.value.filter((existing) => existing.id !== run.id)];
- if (persist) persistRuns();
-}
-
-function updateRun(runId: string, patch: Partial, persist = true): DatabaseBackupRun | null {
- const existing = runs.value.find((run) => run.id === runId);
- if (!existing) return null;
- const updated = { ...existing, ...patch };
- replaceRun(updated, persist);
- return updated;
-}
-
-function backupPathIsReferencedByAnotherRun(filePath: string, excludedRunIds: ReadonlySet): boolean {
- return runs.value.some((run) => !excludedRunIds.has(run.id) && run.files.some((file) => file.filePath === filePath));
-}
-
-function uniqueUnreferencedBackupPaths(paths: readonly string[], excludedRunIds: ReadonlySet): string[] {
- return [...new Set(paths)].filter((filePath) => !backupPathIsReferencedByAnotherRun(filePath, excludedRunIds));
-}
-
-function backupRootsForRuns(selectedRuns: readonly DatabaseBackupRun[], fallback?: string): string[] {
- return [...new Set([...selectedRuns.map((run) => run.destinationDirectory), fallback].filter((root): root is string => Boolean(root)))];
-}
-
-type FinishedDatabaseBackupRun = Omit & { status: Exclude };
-
-function refreshFromStorage() {
- schedules.value = readDatabaseBackupSchedules();
- runs.value = readDatabaseBackupRuns();
-}
-
-export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {}) {
- const connectionStore = useConnectionStore();
- const { addDatabaseExportTask, markDatabaseExportTaskCancelling, registerTaskCancelHandler, restoreDatabaseExportTaskRunning, unregisterTaskCancelHandler, updateDatabaseExportTask } = useExportTracker();
-
- const activeRuns = computed(() => runs.value.filter((run) => activeRunIds.has(run.id)));
-
- function saveSchedule(value: DatabaseBackupSchedule): DatabaseBackupSchedule {
- const now = new Date();
- const existing = schedules.value.find((schedule) => schedule.id === value.id);
- const normalized = normalizeDatabaseBackupSchedule(
- {
- ...value,
- createdAt: existing?.createdAt ?? value.createdAt,
- updatedAt: now.toISOString(),
- },
- now,
- );
- if (!normalized) throw new Error("Invalid database backup schedule");
-
- const timingChanged = !existing || existing.frequency !== normalized.frequency || existing.intervalHours !== normalized.intervalHours || existing.timeOfDay !== normalized.timeOfDay || existing.weekday !== normalized.weekday || (!existing.enabled && normalized.enabled);
- if (timingChanged) normalized.nextRunAt = nextDatabaseBackupRunAt(normalized, now).toISOString();
- if (existing) {
- normalized.lastRunAt = existing.lastRunAt;
- normalized.lastRunStatus = existing.lastRunStatus;
- }
-
- schedules.value = [...schedules.value.filter((schedule) => schedule.id !== normalized.id), normalized].sort((left, right) => left.name.localeCompare(right.name));
- persistSchedules();
- return normalized;
- }
-
- function setScheduleEnabled(scheduleId: string, enabled: boolean) {
- const schedule = schedules.value.find((item) => item.id === scheduleId);
- if (!schedule) return;
- saveSchedule({ ...schedule, enabled });
- }
-
- function deleteSchedule(scheduleId: string): boolean {
- if (activeScheduleIds.has(scheduleId)) return false;
- schedules.value = schedules.value.filter((schedule) => schedule.id !== scheduleId);
- persistSchedules();
- return true;
- }
-
- async function deleteRuns(runIds: readonly string[]): Promise {
- const ids = new Set(runIds);
- const selectedRuns = runs.value.filter((run) => ids.has(run.id) && !activeRunIds.has(run.id));
- if (selectedRuns.length === 0) return;
-
- const deletedIds = new Set(selectedRuns.map((run) => run.id));
- const paths = uniqueUnreferencedBackupPaths(
- selectedRuns.flatMap((run) => run.files.map((file) => file.filePath)),
- deletedIds,
- );
- if (paths.length > 0) await api.deleteDatabaseBackupFiles(paths, backupRootsForRuns(selectedRuns));
- runs.value = runs.value.filter((run) => !deletedIds.has(run.id));
- persistRuns();
- }
-
- async function deleteRun(runId: string): Promise {
- await deleteRuns([runId]);
- }
-
- function renameRun(runId: string, displayName: string): boolean {
- const run = runs.value.find((item) => item.id === runId);
- if (!run || activeRunIds.has(runId)) return false;
- const normalizedName = displayName.trim();
- updateRun(runId, { displayName: normalizedName && normalizedName !== run.scheduleName ? normalizedName : undefined });
- return true;
- }
-
- async function pruneScheduleRuns(schedule: DatabaseBackupSchedule) {
- const staleRuns = databaseBackupRunsToPrune(runs.value, schedule.id, schedule.retentionCount);
- if (staleRuns.length === 0) return;
- const staleIds = new Set(staleRuns.map((run) => run.id));
- const stalePaths = uniqueUnreferencedBackupPaths(
- staleRuns.flatMap((run) => run.files.map((file) => file.filePath)),
- staleIds,
- );
- try {
- if (stalePaths.length > 0) await api.deleteDatabaseBackupFiles(stalePaths, backupRootsForRuns(staleRuns, schedule.destinationDirectory));
- runs.value = runs.value.filter((run) => !staleIds.has(run.id));
- persistRuns();
- } catch (error) {
- appendDebugLog("error", "[DBX][database-backup:retention-error]", error);
- }
- }
-
- async function cancelRun(runId: string): Promise {
- if (!activeRunIds.has(runId)) return false;
- if (cancellationRequested.has(runId)) return true;
- cancellationRequested.add(runId);
- cancellingRunIds.add(runId);
- markDatabaseExportTaskCancelling(runId);
- const exportIds = new Set([runId, activeExportIds.get(runId)].filter((id): id is string => Boolean(id)));
- try {
- await Promise.all([...exportIds].map((exportId) => api.cancelDatabaseExport(exportId)));
- } catch (error) {
- appendDebugLog("warn", "[DBX][database-backup:cancel-request-error]", error);
- cancellationRequested.delete(runId);
- cancellingRunIds.delete(runId);
- restoreDatabaseExportTaskRunning(runId);
- return false;
- }
- return true;
- }
-
- async function runBackup(config: DatabaseBackupExecutionConfig, request: { source: DatabaseBackupRunSource; trigger: DatabaseBackupRunTrigger; scheduleId?: string; displayName: string; runDirectoryPattern?: string }): Promise {
- const backupName = request.displayName;
- const connection = connectionStore.getConfig(config.connectionId);
-
- const startedAt = new Date();
- const runId = generateDatabaseExportId();
- const outputDirectory = request.runDirectoryPattern ? databaseBackupRunDirectory(config.destinationDirectory, request.runDirectoryPattern, backupName, startedAt, runId) : config.destinationDirectory;
- const run: DatabaseBackupRun = {
- id: runId,
- scheduleId: request.scheduleId,
- scheduleName: backupName,
- connectionId: config.connectionId,
- connectionName: connection?.name ?? "",
- destinationDirectory: config.destinationDirectory,
- trigger: request.trigger,
- source: request.source,
- status: "running",
- startedAt: startedAt.toISOString(),
- files: [],
- progressPercent: 0,
- };
- replaceRun(run);
- activeRunIds.add(runId);
- cancellationRequested.delete(runId);
- addDatabaseExportTask(runId, backupName, outputDirectory, request.source === "scheduled" ? "scheduled" : "manual");
- registerTaskCancelHandler(runId, async () => {
- await cancelRun(runId);
- });
-
- let finalStatus: Exclude = "success";
- let finalError = "";
- let finishedRun: FinishedDatabaseBackupRun | null = null;
- let lastProgressPercent = 0;
- const generatedPaths: string[] = [];
- const reservedPaths: string[] = [];
- await databaseBackupConnectionQueue.run(config.connectionId, async () => {
- try {
- if (cancellationRequested.has(runId)) {
- finalStatus = "cancelled";
- return;
- }
- if (!connection || !supportsScheduledDatabaseBackup(connection.db_type)) throw new Error("The backup connection is unavailable or unsupported.");
- await connectionStore.ensureConnected(config.connectionId);
- if (cancellationRequested.has(runId)) {
- finalStatus = "cancelled";
- return;
- }
- const availableDatabases = (await api.listDatabases(config.connectionId)).map((database) => database.name);
- if (cancellationRequested.has(runId)) {
- finalStatus = "cancelled";
- return;
- }
- const selectedDatabases = resolveScheduledDatabaseBackupTargets(config.databases, availableDatabases, connection.db_type);
- if (selectedDatabases.length === 0) throw new Error("No databases are available for this backup schedule.");
-
- let tableNamesCaseSensitive = true;
- if (connection.db_type === "mysql" && config.tableFilterMode !== "all") {
- try {
- const result = await api.executeQuery(config.connectionId, "", "SHOW VARIABLES LIKE 'lower_case_table_names'", undefined, undefined, { maxRows: 1 });
- tableNamesCaseSensitive = databaseBackupTableNamesAreCaseSensitive(connection.db_type, result.rows[0]?.[1] ?? result.rows[0]?.[0]);
- } catch (error) {
- appendDebugLog("warn", "[DBX][database-backup:table-name-case-detection-error]", error);
- }
- if (cancellationRequested.has(runId)) {
- finalStatus = "cancelled";
- return;
- }
- }
-
- let exportIndex = 0;
- let includedTableCount = 0;
- for (const [databaseIndex, database] of selectedDatabases.entries()) {
- if (cancellationRequested.has(runId)) {
- finalStatus = "cancelled";
- break;
- }
- const schemasByDatabase = connection.db_type === "postgres" ? { [database]: await api.listSchemas(config.connectionId, database) } : undefined;
- if (cancellationRequested.has(runId)) {
- finalStatus = "cancelled";
- break;
- }
- const databasePlan = buildAllDatabaseExportPlan({
- databases: [database],
- schemaAware: connection.db_type === "postgres",
- schemasByDatabase,
+ function update(snapshot: DatabaseBackupSnapshot) {
+ schedules.value = snapshot.schedules;
+ runs.value = snapshot.runs;
+ heartbeat.value = snapshot.heartbeat;
+ destinationRoot.value = snapshot.destinationRoot;
+ activeScheduleIds.clear();
+ activeRunIds.clear();
+ for (const run of snapshot.runs) {
+ if (run.status === "running") {
+ activeRunIds.add(run.id);
+ if (run.scheduleId) activeScheduleIds.add(run.scheduleId);
+ if (!tracked.has(run.id)) {
+ tracked.add(run.id);
+ tracker.addDatabaseExportTask(run.id, run.displayName || run.scheduleName, run.destinationDirectory || "", run.source === "scheduled" ? "scheduled" : "manual");
+ tracker.registerTaskCancelHandler(run.id, async () => {
+ await cancelRun(run.id);
});
- if (databasePlan.length === 0) throw new Error(`Database ${database} did not resolve to any schemas.`);
- const scopedDatabasePlan: Array<(typeof databasePlan)[number] & { selectedTables?: string[]; excludedTables?: string[] }> = [];
- for (const item of databasePlan) {
- if (config.tableFilterMode === "all") {
- scopedDatabasePlan.push(item);
- continue;
- }
- const availableTables = (await api.listTables(config.connectionId, item.database, item.schema)).map((table) => table.name);
- if (cancellationRequested.has(runId)) {
- finalStatus = "cancelled";
- break;
- }
- const scope = resolveScheduledDatabaseBackupTableScope(config.tableFilterMode, config.tablePatterns, availableTables, item.database, item.schema, tableNamesCaseSensitive);
- includedTableCount += scope.includedTables.length;
- if (scope.includedTables.length === 0) continue;
- scopedDatabasePlan.push({ ...item, selectedTables: scope.selectedTables, excludedTables: scope.excludedTables });
- }
- if (finalStatus === "cancelled") break;
-
- const snapshot = await api.beginDatabaseBackupSnapshot(config.connectionId, database, runId);
- let snapshotCompleted = false;
- try {
- for (const [planIndex, item] of scopedDatabasePlan.entries()) {
- if (cancellationRequested.has(runId)) {
- finalStatus = "cancelled";
- break;
- }
- exportIndex += 1;
- const childExportId = `${runId}-${exportIndex}`;
- activeExportIds.set(runId, childExportId);
- const filePath = databaseBackupFilePath(outputDirectory, backupName, item.fileStem, startedAt, runId, config.outputCompression, config.fileNamePattern);
- if (generatedPaths.includes(filePath) || activeBackupPaths.has(filePath) || backupPathIsReferencedByAnotherRun(filePath, new Set([runId]))) {
- throw new Error("Backup file path is already used by another run. Include {runId} or another unique value in the file-name template.");
- }
- generatedPaths.push(filePath);
- reservedPaths.push(filePath);
- activeBackupPaths.add(filePath);
- let terminal: Awaited>;
- try {
- terminal = await runDatabaseExportUntilTerminal(
- {
- exportId: childExportId,
- connectionId: config.connectionId,
- database: item.database,
- schema: item.schema,
- filePath,
- selectedTables: item.selectedTables,
- excludedTables: item.excludedTables,
- includeStructure: config.includeStructure,
- includeData: config.includeData,
- includeObjects: config.includeObjects,
- dropTableIfExists: config.dropTableIfExists,
- outputCompression: config.outputCompression,
- failOnError: true,
- preventOverwrite: true,
- snapshotSessionId: snapshot.sessionId,
- batchSize: 1000,
- },
- (progress) => {
- const progressPercent = databaseBackupProgressPercent({
- completedDatabases: databaseIndex,
- totalDatabases: selectedDatabases.length,
- completedExports: planIndex,
- totalExports: scopedDatabasePlan.length,
- currentObjectIndex: progress.objectIndex,
- currentTotalObjects: progress.totalObjects,
- currentExportComplete: progress.status === "Done",
- });
- if (progressPercent !== lastProgressPercent) {
- lastProgressPercent = progressPercent;
- updateRun(runId, { progressPercent }, false);
- }
- updateDatabaseExportTask(runId, {
- ...progress,
- exportId: runId,
- currentObject: `${item.displayName}: ${progress.currentObject || item.displayName}`,
- status: databaseBackupAggregateExportStatus(progress.status, false),
- overallPercent: progressPercent,
- });
- },
- );
- } catch (error: any) {
- if (String(error?.message || error).startsWith("Backup file already exists:")) {
- const index = generatedPaths.lastIndexOf(filePath);
- if (index >= 0) generatedPaths.splice(index, 1);
- }
- throw error;
- }
- activeExportIds.delete(runId);
- if (terminal.status === "Cancelled" || cancellationRequested.has(runId)) {
- finalStatus = "cancelled";
- break;
- }
-
- const file: DatabaseBackupFile = {
- database: item.database,
- schema: item.schema,
- displayName: item.displayName,
- filePath,
- };
- run.files.push(file);
- updateRun(runId, { files: [...run.files] });
- }
- snapshotCompleted = finalStatus === "success";
- } finally {
- await api.rollbackManualTransaction(snapshot.sessionId).catch((error) => {
- if (snapshotCompleted) throw error;
- });
- }
- if (finalStatus !== "success") break;
- lastProgressPercent = databaseBackupProgressPercent({
- completedDatabases: databaseIndex + 1,
- totalDatabases: selectedDatabases.length,
- completedExports: 0,
- totalExports: 0,
- });
- updateRun(runId, { progressPercent: lastProgressPercent }, false);
}
- if (config.tableFilterMode !== "all" && includedTableCount === 0) {
- throw new Error(`No tables matched the configured ${config.tableFilterMode} backup rules.`);
- }
- } catch (error: any) {
- finalStatus = cancellationRequested.has(runId) ? "cancelled" : "failed";
- finalError = error?.message || String(error);
- } finally {
- for (const filePath of reservedPaths) activeBackupPaths.delete(filePath);
- // `runId` is also the cancellation key while a snapshot is waiting
- // for a pool connection. Unlike child exports, it has no exporter
- // task that can clear that key after completion.
- await api.clearDatabaseExportCancellation(runId).catch((error) => {
- appendDebugLog("warn", "[DBX][database-backup:cancel-clear-error]", error);
- });
- if (finalStatus !== "success" && generatedPaths.length > 0) {
- try {
- const cleanupPaths = uniqueUnreferencedBackupPaths(generatedPaths, new Set([runId]));
- if (cleanupPaths.length > 0) await api.deleteDatabaseBackupFiles(cleanupPaths, [config.destinationDirectory]);
- run.files = [];
- } catch (error: any) {
- appendDebugLog("error", "[DBX][database-backup:partial-cleanup-error]", error);
- const cleanupError = error?.message || String(error);
- finalError = finalError ? `${finalError}; failed to remove partial backup files: ${cleanupError}` : cleanupError;
- }
- }
- const completedAt = new Date();
- activeExportIds.delete(runId);
- cancellationRequested.delete(runId);
- cancellingRunIds.delete(runId);
- activeRunIds.delete(runId);
- unregisterTaskCancelHandler(runId);
-
- const updatedRun = updateRun(runId, {
- status: finalStatus,
- completedAt: completedAt.toISOString(),
- files: [...run.files],
- progressPercent: finalStatus === "success" ? 100 : lastProgressPercent,
- error: finalError || undefined,
- });
- finishedRun = updatedRun && updatedRun.status !== "running" ? (updatedRun as FinishedDatabaseBackupRun) : null;
- updateDatabaseExportTask(runId, {
- exportId: runId,
- currentObject: backupName,
- objectIndex: finalStatus === "success" ? run.files.length : 0,
+ } else {
+ cancellingRunIds.delete(run.id);
+ tracker.unregisterTaskCancelHandler(run.id);
+ }
+ if (tracked.has(run.id)) {
+ tracker.updateDatabaseExportTask(run.id, {
+ exportId: run.id,
+ currentObject: run.displayName || run.scheduleName,
+ objectIndex: 0,
totalObjects: run.files.length,
rowsExported: 0,
totalRows: null,
- status: databaseBackupAggregateExportStatus(finalStatus === "success" ? "Done" : finalStatus === "cancelled" ? "Cancelled" : "Error", true),
- error: finalError || null,
- overallPercent: finalStatus === "success" ? 100 : lastProgressPercent,
- });
-
- appendDebugLog(finalStatus === "success" ? "info" : "error", `[DBX][database-backup:${finalStatus}]`, {
- scheduleId: request.scheduleId,
- runId,
- files: run.files.length,
- error: finalError || undefined,
+ status: run.status === "running" ? "Running" : run.status === "success" ? "Done" : run.status === "cancelled" ? "Cancelled" : "Error",
+ error: run.error || null,
+ overallPercent: run.progressPercent || 0,
});
+ if (run.status !== "running") tracked.delete(run.id);
}
- });
- return finishedRun;
- }
-
- async function finalizeScheduledRun(scheduleId: string, trigger: DatabaseBackupRunTrigger, run: FinishedDatabaseBackupRun) {
- const schedule = schedules.value.find((item) => item.id === scheduleId);
- if (!schedule) return;
- const completedAt = run.completedAt ? new Date(run.completedAt) : new Date();
- schedules.value = schedules.value.map((item) =>
- item.id === scheduleId
- ? {
- ...item,
- lastRunAt: completedAt.toISOString(),
- lastRunStatus: run.status,
- nextRunAt: trigger === "scheduled" || Date.parse(item.nextRunAt) <= completedAt.getTime() ? nextDatabaseBackupRunAt(item, completedAt).toISOString() : item.nextRunAt,
- }
- : item,
- );
- persistSchedules();
- if (run.status === "success") await pruneScheduleRuns(schedule);
- }
-
- async function runSchedule(scheduleId: string, trigger: DatabaseBackupRunTrigger = "manual"): Promise {
- if (!isTauriRuntime()) throw new Error("Scheduled database backups are only available in the desktop app.");
- const schedule = schedules.value.find((item) => item.id === scheduleId);
- if (!schedule || activeScheduleIds.has(scheduleId)) return null;
- activeScheduleIds.add(scheduleId);
- try {
- const run = await runBackup(toDatabaseBackupExecutionConfig(schedule), {
- source: "scheduled",
- trigger,
- scheduleId,
- displayName: schedule.name,
- runDirectoryPattern: schedule.runDirectoryPattern,
- });
- if (run) await finalizeScheduledRun(scheduleId, trigger, run);
- return run;
- } finally {
- activeScheduleIds.delete(scheduleId);
}
}
- async function runOneShot(config: DatabaseBackupExecutionConfig, displayName = "Database backup"): Promise {
- if (!isTauriRuntime()) throw new Error("One-shot database backups are only available in the desktop app.");
- if (activeRuns.value.some((run) => run.source === "one-shot")) return null;
- return runBackup(config, { source: "one-shot", trigger: "manual", displayName });
+ async function refresh() {
+ if (refreshing) return refreshing;
+ refreshing = (async () => {
+ try {
+ let snapshot = await api.databaseBackupCommand({ action: "snapshot" });
+ if (!snapshot.migrated) {
+ const legacySchedules = isTauriRuntime() ? readDatabaseBackupSchedules().map((schedule) => ({ ...schedule, timeZone: schedule.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone })) : [];
+ await api.databaseBackupCommand({ action: "migrate", migration: { schedules: legacySchedules, runs: isTauriRuntime() ? readDatabaseBackupRuns() : [] } });
+ // Keep legacy keys as a migration fallback. The backend marker prevents reimporting.
+ snapshot = await api.databaseBackupCommand({ action: "snapshot" });
+ }
+ update(snapshot);
+ error.value = "";
+ } catch (reason) {
+ error.value = reason instanceof Error ? reason.message : String(reason);
+ appendDebugLog("error", "[DBX][database-backup:refresh]", reason);
+ throw reason;
+ }
+ })().finally(() => {
+ refreshing = undefined;
+ });
+ return refreshing;
}
- async function processDueSchedules() {
- if (processingDueSchedules) return;
- processingDueSchedules = true;
- try {
- await connectionStore.initFromDisk();
- const now = new Date();
- const dueSchedules = schedules.value.filter((schedule) => databaseBackupScheduleIsDue(schedule, now));
- for (const schedule of dueSchedules) {
- await runSchedule(schedule.id, "scheduled").catch((error) => {
- appendDebugLog("error", "[DBX][database-backup:scheduler-error]", error);
- });
- }
- } catch (error) {
- appendDebugLog("error", "[DBX][database-backup:scheduler-init-error]", error);
- } finally {
- processingDueSchedules = false;
+ async function refreshAfterMutation() {
+ // A poll started before a mutation may still return the previous state.
+ await refreshing?.catch(() => {});
+ await refresh();
+ }
+
+ async function saveSchedule(schedule: DatabaseBackupSchedule) {
+ const saved = await api.databaseBackupCommand({ action: "save", schedule: { ...schedule, timeZone: schedule.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone } });
+ await refreshAfterMutation();
+ return saved;
+ }
+
+ async function setScheduleEnabled(id: string, enabled: boolean) {
+ const schedule = schedules.value.find((s) => s.id === id);
+ if (schedule) await saveSchedule({ ...schedule, enabled });
+ }
+
+ async function deleteSchedule(id: string) {
+ if (activeScheduleIds.has(id)) return false;
+ await api.databaseBackupCommand({ action: "deleteSchedule", id });
+ await refreshAfterMutation();
+ return true;
+ }
+
+ async function deleteRuns(ids: readonly string[]) {
+ await api.databaseBackupCommand({ action: "deleteRuns", ids: [...ids] });
+ await refreshAfterMutation();
+ }
+
+ async function renameRun(id: string, name: string) {
+ if (activeRunIds.has(id)) return false;
+ await api.databaseBackupCommand({ action: "rename", id, name: name.trim() });
+ await refreshAfterMutation();
+ return true;
+ }
+
+ async function cancelRun(id: string) {
+ const accepted = await api.databaseBackupCommand({ action: "cancel", id });
+ if (accepted) {
+ cancellingRunIds.add(id);
+ tracker.markDatabaseExportTaskCancelling(id);
+ }
+ return accepted;
+ }
+
+ async function waitForRun(run: DatabaseBackupRun): Promise {
+ await refreshAfterMutation();
+ while (true) {
+ const current = runs.value.find((r) => r.id === run.id);
+ if (!current || current.status !== "running") return current || null;
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ await refresh();
}
}
- function onStorage(event: StorageEvent) {
- if (event.key && event.key !== DATABASE_BACKUP_SCHEDULES_STORAGE_KEY && event.key !== DATABASE_BACKUP_RUNS_STORAGE_KEY) return;
- refreshFromStorage();
- void processDueSchedules();
+ async function runSchedule(id: string) {
+ if (activeScheduleIds.has(id)) return null;
+ return waitForRun(await api.databaseBackupCommand({ action: "run", request: { scheduleId: id } }));
}
- function onConfigChanged() {
- void processDueSchedules();
+ async function runOneShot(config: DatabaseBackupExecutionConfig, displayName = "Database backup") {
+ return waitForRun(await api.databaseBackupCommand({ action: "run", request: { config, displayName, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone } }));
}
- if (options.scheduler) {
- onMounted(() => {
- if (schedulerRegistered || !isTauriRuntime()) return;
- schedulerRegistered = true;
- window.addEventListener("storage", onStorage);
- window.addEventListener(DATABASE_BACKUP_CONFIG_CHANGED_EVENT, onConfigChanged);
- schedulerTimer = window.setInterval(() => void processDueSchedules(), SCHEDULER_INTERVAL_MS);
- void processDueSchedules();
- });
-
- onUnmounted(() => {
- if (!schedulerRegistered) return;
- schedulerRegistered = false;
- if (schedulerTimer) window.clearInterval(schedulerTimer);
- schedulerTimer = undefined;
- window.removeEventListener("storage", onStorage);
- window.removeEventListener(DATABASE_BACKUP_CONFIG_CHANGED_EVENT, onConfigChanged);
- });
- }
+ onMounted(() => {
+ subscribers += 1;
+ if (!timer)
+ timer = setInterval(() => {
+ void refresh().catch(() => {});
+ }, 2000);
+ void refresh().catch(() => {});
+ });
+ onUnmounted(() => {
+ subscribers -= 1;
+ if (!subscribers && timer) {
+ clearInterval(timer);
+ timer = undefined;
+ }
+ });
return {
schedules,
@@ -571,15 +173,18 @@ export function useScheduledDatabaseBackups(options: { scheduler?: boolean } = {
activeRunIds,
cancellingRunIds,
activeRuns,
+ heartbeat,
+ destinationRoot,
+ error,
saveSchedule,
setScheduleEnabled,
deleteSchedule,
- deleteRun,
deleteRuns,
+ deleteRun: (id: string) => deleteRuns([id]),
renameRun,
runSchedule,
runOneShot,
cancelRun,
- processDueSchedules,
+ processDueSchedules: refresh,
};
}
diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts
index 2d568ce287..d601e5eb0b 100644
--- a/apps/desktop/src/i18n/locales/az.ts
+++ b/apps/desktop/src/i18n/locales/az.ts
@@ -8358,6 +8358,14 @@ export default withEnglishFallback({
runInBackground: "Arxa planda işlət",
},
databaseBackup: {
+ savedTimeZoneHint: "Növbəti icra planda saxlanılan saat qurşağına əsaslanır.",
+ backgroundWorker: "DBX bağlandıqdan sonra ehtiyat nüsxələməni davam etdir",
+ backgroundLoginScope: "İstifadəçi sessiyası açıq olduqda işləyir. Kompüter oyaq qalmalıdır.",
+ serverRuntime: "Brauzer bağlansa belə server ehtiyat nüsxələməni davam etdirir.",
+ workerLastSeen: "İşçinin son cavabı: {time}",
+ workerStarting: "Ehtiyat nüsxələmə işçisi gözlənilir",
+ serverDestination: "Server ehtiyat nüsxələrinin kök qovluğu: {path}",
+ timeZone: "Saat qurşağı (IANA)",
title: "Verilənlər bazalarının ehtiyat nüsxələri",
schedules: "Ehtiyat nüsxələmə cədvəlləri",
runtimeRequirement: "Planlaşdırılmış ehtiyat nüsxələmə DBX açıq olduqda, o cümlədən sistem panelinə kiçildildikdə işləyir.",
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 0b5b52b092..83fec09002 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -8912,6 +8912,14 @@ export default {
runInBackground: "Run in background",
},
databaseBackup: {
+ savedTimeZoneHint: "The next run uses the schedule's saved time zone.",
+ backgroundWorker: "Background backups after closing DBX",
+ backgroundLoginScope: "Runs while you are signed in. The computer must remain awake.",
+ serverRuntime: "The server runs backups even when the browser is closed.",
+ workerLastSeen: "Last worker heartbeat: {time}",
+ workerStarting: "Waiting for backup worker",
+ serverDestination: "Server backup root: {path}",
+ timeZone: "Time zone (IANA)",
title: "Database Backups",
schedules: "Backup schedules",
runtimeRequirement: "Scheduled backups run while DBX is open, including when it is minimized to the tray.",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 1aa1383a15..d0a502b175 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -9824,6 +9824,14 @@ export default withEnglishFallback({
blockIoChart: "E/S de bloques",
},
databaseBackup: {
+ savedTimeZoneHint: "La próxima ejecución usa la zona horaria guardada en la programación.",
+ backgroundWorker: "Continuar copias al cerrar DBX",
+ backgroundLoginScope: "Funciona durante la sesión del usuario. El equipo debe permanecer activo.",
+ serverRuntime: "El servidor ejecuta las copias aunque se cierre el navegador.",
+ workerLastSeen: "Última respuesta del proceso: {time}",
+ workerStarting: "Esperando al proceso de copia",
+ serverDestination: "Directorio raíz de copias del servidor: {path}",
+ timeZone: "Zona horaria (IANA)",
title: "Copia de seguridad de la base de datos",
schedules: "Planes de copia de seguridad",
runtimeRequirement: "DBX ejecutará copias de seguridad programadas cuando esté abierto, incluso cuando esté minimizado en la bandeja.",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 22c7138408..1821dfe5aa 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -9824,6 +9824,14 @@ export default withEnglishFallback({
blockIoChart: "I/O a blocchi",
},
databaseBackup: {
+ savedTimeZoneHint: "La prossima esecuzione usa il fuso orario salvato nella pianificazione.",
+ backgroundWorker: "Continua i backup dopo la chiusura di DBX",
+ backgroundLoginScope: "Funziona durante la sessione utente. Il computer deve restare attivo.",
+ serverRuntime: "Il server esegue i backup anche quando il browser è chiuso.",
+ workerLastSeen: "Ultima risposta del processo: {time}",
+ workerStarting: "In attesa del processo di backup",
+ serverDestination: "Directory principale dei backup sul server: {path}",
+ timeZone: "Fuso orario (IANA)",
title: "Backup del database",
schedules: "Pianificazioni di backup",
runtimeRequirement: "I backup programmati vengono eseguiti quando DBX è aperto, anche se ridotto a icona nella barra delle applicazioni.",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 459419be5c..ef71ed3916 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -9874,6 +9874,14 @@ export default withEnglishFallback({
blockIoChart: "ブロック I/O",
},
databaseBackup: {
+ savedTimeZoneHint: "次回の実行はスケジュールに保存されたタイムゾーンで計算されます。",
+ backgroundWorker: "DBX 終了後もバックアップを継続",
+ backgroundLoginScope: "ログイン中に動作します。コンピューターをスリープさせないでください。",
+ serverRuntime: "ブラウザーを閉じてもサーバーでバックアップを実行します。",
+ workerLastSeen: "ワーカーの最終応答: {time}",
+ workerStarting: "バックアップワーカーを待機中",
+ serverDestination: "サーバーのバックアップルート: {path}",
+ timeZone: "タイムゾーン (IANA)",
title: "データベースバックアップ",
schedules: "バックアップスケジュール",
runtimeRequirement: "DBXが開いている状態(トレイに最小化したときも含む)で、定期バックアップが実行されます。",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index 60a024e564..52f7c841f5 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -8025,6 +8025,14 @@ export default withEnglishFallback({
runInBackground: "백그라운드에서 실행",
},
databaseBackup: {
+ savedTimeZoneHint: "다음 실행 시간은 일정에 저장된 시간대로 계산됩니다.",
+ backgroundWorker: "DBX 종료 후에도 백업 실행",
+ backgroundLoginScope: "로그인한 동안 실행됩니다. 컴퓨터가 절전 상태가 아니어야 합니다.",
+ serverRuntime: "브라우저를 닫아도 서버에서 백업을 실행합니다.",
+ workerLastSeen: "작업자의 마지막 응답: {time}",
+ workerStarting: "백업 작업자 대기 중",
+ serverDestination: "서버 백업 루트: {path}",
+ timeZone: "시간대 (IANA)",
title: "데이터베이스 백업",
schedules: "백업 일정",
runtimeRequirement: "예약된 백업은 DBX가 열려 있는 동안(트레이에 최소화된 경우 포함) 실행됩니다.",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 18b87178f0..fe256e186c 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -9825,6 +9825,14 @@ export default withEnglishFallback({
blockIoChart: "E/S de blocos",
},
databaseBackup: {
+ savedTimeZoneHint: "A próxima execução usa o fuso horário salvo no agendamento.",
+ backgroundWorker: "Continuar backups após fechar o DBX",
+ backgroundLoginScope: "Funciona durante a sessão do usuário. O computador deve permanecer ativo.",
+ serverRuntime: "O servidor executa os backups mesmo com o navegador fechado.",
+ workerLastSeen: "Última resposta do processo: {time}",
+ workerStarting: "Aguardando o processo de backup",
+ serverDestination: "Diretório raiz dos backups no servidor: {path}",
+ timeZone: "Fuso horário (IANA)",
title: "Backup de banco de dados",
schedules: "Planos de backup",
runtimeRequirement: "O DBX executará backups agendados quando estiver aberto, inclusive quando minimizado na bandeja.",
diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts
index c787ef4e27..403dae0c82 100644
--- a/apps/desktop/src/i18n/locales/tr.ts
+++ b/apps/desktop/src/i18n/locales/tr.ts
@@ -8228,6 +8228,14 @@ export default withEnglishFallback({
runInBackground: "Arka planda çalıştır",
},
databaseBackup: {
+ savedTimeZoneHint: "Sonraki çalıştırma, planda kayıtlı saat dilimini kullanır.",
+ backgroundWorker: "DBX kapatıldıktan sonra yedeklemeye devam et",
+ backgroundLoginScope: "Oturum açıkken çalışır. Bilgisayar uyanık kalmalıdır.",
+ serverRuntime: "Tarayıcı kapatılsa bile sunucu yedeklemeyi sürdürür.",
+ workerLastSeen: "Çalışanın son yanıtı: {time}",
+ workerStarting: "Yedekleme çalışanı bekleniyor",
+ serverDestination: "Sunucu yedekleme kök dizini: {path}",
+ timeZone: "Saat dilimi (IANA)",
title: "Veritabanı Yedekleri",
schedules: "Yedekleme zamanlamaları",
runtimeRequirement: "Zamanlanmış yedeklemeler, DBX açıkken (tepsiye küçültülmüş olsa bile) çalışır.",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index d0c5f7eb0e..d68f8fa0da 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -8881,6 +8881,14 @@ export default withEnglishFallback({
runInBackground: "后台运行",
},
databaseBackup: {
+ savedTimeZoneHint: "下次执行时间按计划中保存的时区计算。",
+ backgroundWorker: "关闭 DBX 后继续备份",
+ backgroundLoginScope: "后台服务在登录期间运行,计算机须保持唤醒。",
+ serverRuntime: "备份由服务器执行,关闭浏览器不影响运行。",
+ workerLastSeen: "后台最近响应:{time}",
+ workerStarting: "等待备份后台启动",
+ serverDestination: "服务器备份根目录:{path}",
+ timeZone: "时区(IANA)",
title: "数据库备份",
schedules: "备份计划",
runtimeRequirement: "DBX 处于打开状态时会执行定时备份,包括最小化到托盘时。",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index a987847aaf..1bfdea40b0 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -9822,6 +9822,14 @@ export default withEnglishFallback({
blockIoChart: "區塊 I/O",
},
databaseBackup: {
+ savedTimeZoneHint: "下次執行時間依計畫中儲存的時區計算。",
+ backgroundWorker: "關閉 DBX 後繼續備份",
+ backgroundLoginScope: "背景服務在登入期間執行,電腦須保持喚醒。",
+ serverRuntime: "備份由伺服器執行,關閉瀏覽器不影響執行。",
+ workerLastSeen: "背景最近回應:{time}",
+ workerStarting: "等待備份背景服務啟動",
+ serverDestination: "伺服器備份根目錄:{path}",
+ timeZone: "時區(IANA)",
title: "資料庫備份",
schedules: "備份計畫",
runtimeRequirement: "DBX 處於開啟狀態時會執行定時備份,包含最小化到托盤時。",
diff --git a/apps/desktop/src/lib/__tests__/backup/scheduledDatabaseBackup.spec.ts b/apps/desktop/src/lib/__tests__/backup/scheduledDatabaseBackup.spec.ts
index fac2c3a2c7..cc7921dc3f 100644
--- a/apps/desktop/src/lib/__tests__/backup/scheduledDatabaseBackup.spec.ts
+++ b/apps/desktop/src/lib/__tests__/backup/scheduledDatabaseBackup.spec.ts
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import {
databaseBackupFileNamePatternIsValid,
databaseBackupFilePath,
+ databaseBackupRunDirectory,
+ databaseBackupTimestamp,
sanitizeDatabaseBackupFileSegment,
databaseBackupRunsToPrune,
databaseBackupTableMatchesPattern,
@@ -28,6 +30,13 @@ function run(overrides: Record = {}) {
}
describe("database backup run persistence", () => {
+ it("renders preview names in the saved zone rather than the browser zone", () => {
+ const instant = "2026-09-12T18:00:00Z";
+ expect(databaseBackupTimestamp(instant, "Asia/Shanghai")).toBe("20260913-020000");
+ expect(databaseBackupTimestamp(instant, "America/New_York")).toBe("20260912-140000");
+ expect(databaseBackupRunDirectory("/backups", "{date}/{timestamp}", "Daily", instant, "12345678", "Asia/Shanghai")).toBe("/backups/20260913/20260913020000");
+ expect(databaseBackupFilePath("/backups", "Daily", "app", instant, "12345678", "gzip", "{timestamp}", "Asia/Shanghai")).toBe("/backups/20260913-020000__app.sql.gz");
+ });
it("uses a gzip extension when the backup output is compressed", () => {
expect(databaseBackupFilePath("/backups", "Nightly", "app", startedAt, "run-12345678", "gzip")).toMatch(/\.sql\.gz$/);
});
diff --git a/apps/desktop/src/lib/backend/__tests__/databaseBackupRestore.spec.ts b/apps/desktop/src/lib/backend/__tests__/databaseBackupRestore.spec.ts
new file mode 100644
index 0000000000..1454b8ffc3
--- /dev/null
+++ b/apps/desktop/src/lib/backend/__tests__/databaseBackupRestore.spec.ts
@@ -0,0 +1,28 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { prepareDatabaseBackupRestore, previewSqlFile, releaseSqlFilePreview } from "@/lib/backend/http";
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe("Web backup restore transport", () => {
+ it("returns the server preview from the record-authorized restore endpoint", async () => {
+ const preview = { fileName: "backup.sql", filePath: "/server/tmp/sql_file/restore-token/backup.sql", preview: "SELECT 1;", cleanupToken: "restore-token" };
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => preview });
+ vi.stubGlobal("fetch", fetchMock);
+ await expect(prepareDatabaseBackupRestore("run/1", 2)).resolves.toEqual(preview);
+ expect(fetchMock).toHaveBeenCalledWith("/api/database-backups/run%2F1/files/2/restore", expect.objectContaining({ method: "POST", body: "{}" }));
+ });
+
+ it("continues rejecting arbitrary server paths without making a request", async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+ await expect(previewSqlFile("/etc/passwd")).rejects.toThrow("requires a File object");
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("releases only by the opaque cleanup token", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => null });
+ vi.stubGlobal("fetch", fetchMock);
+ await releaseSqlFilePreview("restore-token");
+ expect(fetchMock).toHaveBeenCalledWith("/api/sql-file/preview/release", expect.objectContaining({ method: "POST", body: '{"cleanupToken":"restore-token"}' }));
+ });
+});
diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts
index fe0e24bfbd..b00c9dd4a4 100644
--- a/apps/desktop/src/lib/backend/api.ts
+++ b/apps/desktop/src/lib/backend/api.ts
@@ -177,6 +177,10 @@ export const savedSqlStorageDir = forward("savedSqlStorageDir");
export const openSavedSqlStorageDir = forward("openSavedSqlStorageDir");
export const revealPathInFileManager = forward("revealPathInFileManager");
export const deleteDatabaseBackupFiles = forward("deleteDatabaseBackupFiles");
+export const databaseBackupCommand = forward("databaseBackupCommand");
+export const databaseBackupBackground = forward("databaseBackupBackground");
+export const downloadDatabaseBackupFile = forward("downloadDatabaseBackupFile");
+export const prepareDatabaseBackupRestore = forward("prepareDatabaseBackupRestore");
export const isSqliteDatabaseFile = forward("isSqliteDatabaseFile");
export const backupSqliteDatabase = forward("backupSqliteDatabase");
export const restoreSqliteDatabase = forward("restoreSqliteDatabase");
@@ -431,6 +435,7 @@ export const listLocalSshKeys = forward("listLocalSshKeys");
// SQL File Execution
export const previewSqlFile = forward("previewSqlFile");
+export const releaseSqlFilePreview = forward("releaseSqlFilePreview");
export const executeSqlFile = forward("executeSqlFile");
export const executeSqlFiles = forward("executeSqlFiles");
export const cancelSqlFileExecution = forward("cancelSqlFileExecution");
diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts
index 7d2e95f95f..89ae1f8b59 100644
--- a/apps/desktop/src/lib/backend/http.ts
+++ b/apps/desktop/src/lib/backend/http.ts
@@ -1,6 +1,26 @@
import type { MongoDumpFormat, MongoDumpSourceInput, MongoDumpCatalog, MongoRestoreSourcePreview, MongoDatabaseDumpRequest, MongoDatabaseRestoreRequest, MongoDatabaseDumpProgress } from "./mongodbDumpTypes";
import type { MongoRestoreUpload, MongoSourceReadOptions } from "./mongodbDumpTypes";
import type { UserSkillRootSettings, UserSkillsListResult, UserSkillsReadResult } from "@/types/userSkills";
+import type { DatabaseBackupCommand, DatabaseBackupBackgroundStatus } from "@/lib/backup/backgroundDatabaseBackup";
+
+export function databaseBackupCommand(command: DatabaseBackupCommand): Promise {
+ return post("/api/database-backups", command);
+}
+
+export async function databaseBackupBackground(enabled?: boolean): Promise {
+ if (enabled !== undefined) throw new Error("The server manages background backups");
+ return { enabled: true, platform: "server" };
+}
+
+export async function downloadDatabaseBackupFile(runId: string, index: number): Promise {
+ const anchor = document.createElement("a");
+ anchor.href = apiUrl(`/api/database-backups/${encodeURIComponent(runId)}/files/${index}`);
+ anchor.click();
+}
+
+export function prepareDatabaseBackupRestore(runId: string, index: number): Promise {
+ return post(`/api/database-backups/${encodeURIComponent(runId)}/files/${index}/restore`, {});
+}
import type {
ConnectionConfig,
ConnectionTestResult,
@@ -2528,6 +2548,10 @@ export async function previewSqlFile(fileOrPath: string | File): Promise {
+ return post("/api/sql-file/preview/release", { cleanupToken });
+}
+
export async function executeSqlFile(request: SqlFileRequest): Promise {
return post("/api/sql-file/execute", { request });
}
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index dd0b4dcad3..0e1688db15 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -2,6 +2,23 @@ import { invoke as tauriInvoke } from "@tauri-apps/api/core";
import type { MongoDumpFormat, MongoDumpSourceInput, MongoDumpCatalog, MongoRestoreSourcePreview, MongoDatabaseDumpRequest, MongoDatabaseRestoreRequest, MongoDatabaseDumpProgress } from "./mongodbDumpTypes";
import type { MongoRestoreUpload, MongoSourceReadOptions } from "./mongodbDumpTypes";
import type { UserSkillRootSettings, UserSkillsListResult, UserSkillsReadResult } from "@/types/userSkills";
+import type { DatabaseBackupCommand, DatabaseBackupBackgroundStatus } from "@/lib/backup/backgroundDatabaseBackup";
+
+export function databaseBackupCommand(command: DatabaseBackupCommand): Promise {
+ return invoke("database_backup_command", { command });
+}
+
+export function databaseBackupBackground(enabled?: boolean): Promise {
+ return invoke("database_backup_background", { enabled });
+}
+
+export async function downloadDatabaseBackupFile(_runId: string, _index: number): Promise {
+ throw new Error("Use the file manager to access desktop backup files");
+}
+
+export function prepareDatabaseBackupRestore(id: string, index: number): Promise {
+ return invoke("database_backup_command", { command: { action: "file", id, index } });
+}
import { assertUpdateAllowsCommand } from "@/lib/app/updatePreparation";
import { collectBrowserSupportInfo } from "@/lib/app/supportInfo";
// Re-exported below so the HTTP transport shares one definition; imported here
@@ -5018,8 +5035,11 @@ export interface SqlFilePreview {
establishesDatabaseContext?: boolean;
packageFilePaths?: string[];
packagePartCount?: number;
+ cleanupToken?: string;
}
+export async function releaseSqlFilePreview(_cleanupToken: string): Promise {}
+
export interface SqlFileProgress {
executionId: string;
status: SqlFileStatus;
diff --git a/apps/desktop/src/lib/backup/backgroundDatabaseBackup.ts b/apps/desktop/src/lib/backup/backgroundDatabaseBackup.ts
new file mode 100644
index 0000000000..597f31747e
--- /dev/null
+++ b/apps/desktop/src/lib/backup/backgroundDatabaseBackup.ts
@@ -0,0 +1,25 @@
+import type { DatabaseBackupExecutionConfig, DatabaseBackupRun, DatabaseBackupSchedule } from "./scheduledDatabaseBackup";
+
+export interface DatabaseBackupSnapshot {
+ schedules: DatabaseBackupSchedule[];
+ runs: DatabaseBackupRun[];
+ migrated: boolean;
+ heartbeat: string | null;
+ destinationRoot: string | null;
+}
+
+export type DatabaseBackupCommand =
+ | { action: "snapshot" }
+ | { action: "preview"; schedule: DatabaseBackupSchedule }
+ | { action: "save"; schedule: DatabaseBackupSchedule }
+ | { action: "deleteSchedule"; id: string }
+ | { action: "migrate"; migration: { schedules: DatabaseBackupSchedule[]; runs: DatabaseBackupRun[] } }
+ | { action: "run"; request: { scheduleId?: string; config?: DatabaseBackupExecutionConfig; displayName?: string; timeZone?: string } }
+ | { action: "cancel"; id: string }
+ | { action: "rename"; id: string; name: string }
+ | { action: "deleteRuns"; ids: string[] };
+
+export interface DatabaseBackupBackgroundStatus {
+ enabled: boolean;
+ platform: string;
+}
diff --git a/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts b/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts
index b1ea77451d..c686260d34 100644
--- a/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts
+++ b/apps/desktop/src/lib/backup/scheduledDatabaseBackup.ts
@@ -126,6 +126,7 @@ export interface DatabaseBackupExecutionConfig {
}
export interface DatabaseBackupSchedule extends DatabaseBackupExecutionConfig {
+ timeZone?: string;
id: string;
name: string;
enabled: boolean;
@@ -251,6 +252,7 @@ export function normalizeDatabaseBackupSchedule(value: unknown, now = new Date()
frequency,
intervalHours: boundedInteger(input.intervalHours, 6, 1, 168),
timeOfDay: normalizeDatabaseBackupTime(input.timeOfDay),
+ timeZone: typeof input.timeZone === "string" ? input.timeZone : undefined,
weekday: boundedInteger(input.weekday, 1, 0, 6),
includeStructure: booleanValue(input.includeStructure, true),
includeData: booleanValue(input.includeData, true),
@@ -411,15 +413,15 @@ export function normalizeDatabaseBackupRunDirectoryPattern(value: unknown): stri
return stringValue(value).trim() || DEFAULT_DATABASE_BACKUP_RUN_DIRECTORY_PATTERN;
}
-function renderDatabaseBackupRunDirectoryPattern(pattern: string, scheduleName: string, startedAt: Date | string, runId: string): string {
- const timestamp = databaseBackupTimestamp(startedAt);
+function renderDatabaseBackupRunDirectoryPattern(pattern: string, scheduleName: string, startedAt: Date | string, runId: string, timeZone?: string): string {
+ const timestamp = databaseBackupTimestamp(startedAt, timeZone);
const compactTimestamp = timestamp.replace("-", "");
return normalizeDatabaseBackupRunDirectoryPattern(pattern).replaceAll("{schedule}", sanitizeDatabaseBackupFileSegment(scheduleName)).replaceAll("{date}", timestamp.slice(0, 8)).replaceAll("{timestamp}", compactTimestamp).replaceAll("{runId}", sanitizeDatabaseBackupFileSegment(runId).slice(0, 8));
}
-export function databaseBackupRunDirectory(directory: string, pattern: string, scheduleName: string, startedAt: Date | string, runId: string): string {
+export function databaseBackupRunDirectory(directory: string, pattern: string, scheduleName: string, startedAt: Date | string, runId: string, timeZone?: string): string {
const normalizedPattern = normalizeDatabaseBackupRunDirectoryPattern(pattern);
- const rendered = renderDatabaseBackupRunDirectoryPattern(normalizedPattern, scheduleName, startedAt, runId);
+ const rendered = renderDatabaseBackupRunDirectoryPattern(normalizedPattern, scheduleName, startedAt, runId, timeZone);
if (!rendered || /^[\\/]|^[a-zA-Z]:/.test(rendered) || /[\\/]$/.test(rendered)) {
throw new Error("Backup run directory template must be a relative path.");
}
@@ -455,9 +457,9 @@ export function databaseBackupFileNamePatternIsValid(pattern: string): boolean {
return raw === raw.trim() && !/[\\/:*?"<>|]/.test(raw) && !/[. ]$/.test(raw);
}
-function renderDatabaseBackupFileNamePattern(pattern: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string): string {
+function renderDatabaseBackupFileNamePattern(pattern: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string, timeZone?: string): string {
const normalized = normalizeDatabaseBackupFileNamePattern(pattern);
- const timestamp = databaseBackupTimestamp(startedAt);
+ const timestamp = databaseBackupTimestamp(startedAt, timeZone);
const variables = {
"{schedule}": sanitizeDatabaseBackupFileSegment(scheduleName),
"{date}": timestamp.slice(0, 8),
@@ -470,8 +472,13 @@ function renderDatabaseBackupFileNamePattern(pattern: string, scheduleName: stri
return [sanitizeDatabaseBackupFileSegment(rendered), ...requiredSuffixes].join("__");
}
-export function databaseBackupTimestamp(value: Date | string): string {
+export function databaseBackupTimestamp(value: Date | string, timeZone?: string): string {
const date = value instanceof Date ? value : new Date(value);
+ if (timeZone) {
+ const parts = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23" }).formatToParts(date);
+ const part = (name: string) => parts.find((item) => item.type === name)?.value || "";
+ return `${part("year")}${part("month")}${part("day")}-${part("hour")}${part("minute")}${part("second")}`;
+ }
const part = (number: number) => String(number).padStart(2, "0");
return `${date.getFullYear()}${part(date.getMonth() + 1)}${part(date.getDate())}-${part(date.getHours())}${part(date.getMinutes())}${part(date.getSeconds())}`;
}
@@ -481,8 +488,8 @@ export function joinDatabaseBackupPath(directory: string, fileName: string): str
return `${directory.replace(/[\\/]+$/, "")}${separator}${fileName}`;
}
-export function databaseBackupFilePath(directory: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string, outputCompression: DatabaseBackupOutputCompression = "none", fileNamePattern?: string): string {
+export function databaseBackupFilePath(directory: string, scheduleName: string, fileStem: string, startedAt: Date | string, runId: string, outputCompression: DatabaseBackupOutputCompression = "none", fileNamePattern?: string, timeZone?: string): string {
const suffix = outputCompression === "gzip" ? ".sql.gz" : ".sql";
- const fileName = `${renderDatabaseBackupFileNamePattern(fileNamePattern ?? DEFAULT_DATABASE_BACKUP_FILE_NAME_PATTERN, scheduleName, fileStem, startedAt, runId)}${suffix}`;
+ const fileName = `${renderDatabaseBackupFileNamePattern(fileNamePattern ?? DEFAULT_DATABASE_BACKUP_FILE_NAME_PATTERN, scheduleName, fileStem, startedAt, runId, timeZone)}${suffix}`;
return joinDatabaseBackupPath(directory, fileName);
}
diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts
index e46e7d794f..11fdcd6a47 100644
--- a/apps/desktop/src/stores/connectionStore.ts
+++ b/apps/desktop/src/stores/connectionStore.ts
@@ -1,4 +1,5 @@
import { defineStore } from "pinia";
+import type { SqlFilePreview } from "@/lib/backend/api";
import { uuid } from "@/lib/common/utils";
import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin";
import { ref, computed, watch, markRaw } from "vue";
@@ -575,7 +576,7 @@ export const useConnectionStore = defineStore("connection", () => {
schema?: string;
tableName?: string;
} | null>(null);
- const sqlFileSource = ref<{ connectionId: string; database: string; filePath?: string } | null>(null);
+ const sqlFileSource = ref<{ connectionId: string; database: string; filePath?: string; preview?: SqlFilePreview } | null>(null);
const diagramSource = ref<{
connectionId: string;
database: string;
diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml
index 551facfb9b..eb56a1d3c7 100644
--- a/crates/dbx-core/Cargo.toml
+++ b/crates/dbx-core/Cargo.toml
@@ -36,6 +36,8 @@ log = "0.4"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["compat"] }
chrono = { version = "0.4", features = ["serde"] }
+chrono-tz = "0.10"
+fs2 = "0.4"
uuid = { version = "1", features = ["v4", "serde"] }
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
deadpool-postgres = { version = "0.14", features = ["rt_tokio_1"] }
diff --git a/crates/dbx-core/src/data/database_export.rs b/crates/dbx-core/src/data/database_export.rs
index ba8c3f3e98..6d8c0c31f3 100644
--- a/crates/dbx-core/src/data/database_export.rs
+++ b/crates/dbx-core/src/data/database_export.rs
@@ -2350,6 +2350,29 @@ async fn save_export_destination_identity(
state.storage.save_state(&export_destination_state_key(dir), &value, "application/octet-stream").await
}
+/// Read/delete operations must not trust a replaced backup mount or create a missing directory.
+pub async fn verify_export_destination_identity(
+ state: &crate::connection::AppState,
+ dir: &std::path::Path,
+) -> Result<(), String> {
+ if !dir.is_dir() {
+ return Err(format!("Backup directory is unavailable: {}", dir.display()));
+ }
+ let recorded = state
+ .storage
+ .load_state(&export_destination_state_key(dir))
+ .await?
+ .map(|(bytes, _)| ExportDestinationIdentity::decode(&bytes))
+ .transpose()?
+ .flatten();
+ if recorded.as_ref().is_some_and(|identity| {
+ recorded_export_destination_identity_mismatch(identity, export_destination_identity_for_path(dir).as_ref())
+ }) {
+ return Err(format!("Backup directory now resolves to a different filesystem: {}", dir.display()));
+ }
+ Ok(())
+}
+
/// Returns whether this macOS destination still has the transient, untagged
/// `st_dev` identity written by DBX versions before persistent volume UUIDs
/// were introduced. The caller must require an explicit directory selection
@@ -2692,6 +2715,19 @@ fn export_source_file_name(file_path: &str) -> String {
format!("{stem}.sql")
}
+tokio::task_local! {
+ static TRACKED_BACKUP_DESTINATION: (std::path::PathBuf, Arc);
+}
+
+/// Tracks successful create_new separately from preparation errors and path collisions.
+pub(crate) async fn track_backup_destination(
+ path: std::path::PathBuf,
+ created: Arc,
+ operation: F,
+) -> F::Output {
+ TRACKED_BACKUP_DESTINATION.scope((path, created), operation).await
+}
+
async fn create_database_export_writer(
state: &Arc,
request: &DatabaseExportRequest,
@@ -2744,6 +2780,13 @@ async fn create_database_export_writer(
// at the same path in between. Re-check the identity of the handle we
// actually opened, not just the path, and refuse to keep a backup that
// landed on the wrong filesystem. See #6327.
+ if request.prevent_overwrite {
+ let _ = TRACKED_BACKUP_DESTINATION.try_with(|(path, created)| {
+ if path == std::path::Path::new(&request.file_path) {
+ created.store(true, std::sync::atomic::Ordering::Relaxed);
+ }
+ });
+ }
let opened_destination_identity = export_destination_identity_for_file(&file);
if export_destination_identity_mismatch(
expected_destination_identity.as_ref(),
@@ -4253,6 +4296,37 @@ mod tests {
assert_eq!(output, "SELECT 1;\n");
}
+ #[tokio::test]
+ async fn scheduled_backup_destination_tracking_grants_ownership_only_after_create_new() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join("tracked.sql");
+ let state = Arc::new(test_app_state(directory.path()).await);
+ let mut request = export_request(true, true, true, Vec::new());
+ request.file_path = path.to_string_lossy().into_owned();
+ request.prevent_overwrite = true;
+ let created = Arc::new(std::sync::atomic::AtomicBool::new(false));
+ let writer = super::track_backup_destination(
+ path.clone(),
+ created.clone(),
+ create_database_export_writer(&state, &request),
+ )
+ .await
+ .unwrap();
+ assert!(created.load(std::sync::atomic::Ordering::Relaxed));
+ drop(writer);
+ std::fs::write(&path, b"keep existing").unwrap();
+ let collision = Arc::new(std::sync::atomic::AtomicBool::new(false));
+ assert!(super::track_backup_destination(
+ path.clone(),
+ collision.clone(),
+ create_database_export_writer(&state, &request)
+ )
+ .await
+ .is_err());
+ assert!(!collision.load(std::sync::atomic::Ordering::Relaxed));
+ assert_eq!(std::fs::read(path).unwrap(), b"keep existing");
+ }
+
#[tokio::test]
async fn backup_writer_does_not_overwrite_an_existing_file() {
let directory = tempfile::tempdir().unwrap();
diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs
index 70b353b0f7..4337f46350 100644
--- a/crates/dbx-core/src/lib.rs
+++ b/crates/dbx-core/src/lib.rs
@@ -81,6 +81,7 @@ pub use persistence::saved_sql;
pub use query::query_cancel;
pub use query::redis_ops;
pub use safety::risk_metrics;
+pub mod scheduled_backup;
pub mod schema;
pub use connection::session_credentials;
pub use connection::task_supervisor;
diff --git a/crates/dbx-core/src/scheduled_backup/engine.rs b/crates/dbx-core/src/scheduled_backup/engine.rs
new file mode 100644
index 0000000000..ce73fc582e
--- /dev/null
+++ b/crates/dbx-core/src/scheduled_backup/engine.rs
@@ -0,0 +1,437 @@
+use chrono::Utc;
+use std::{fs::OpenOptions, path::Path, sync::Arc, time::Duration};
+use tokio::sync::watch;
+use tokio_util::sync::CancellationToken;
+
+use super::{models::*, BackupService};
+use crate::{
+ connection::AppState,
+ database_export::{self, DatabaseExportOutputCompression, DatabaseExportRequest, ExportStatus},
+ models::connection::DatabaseType,
+ query, schema,
+};
+
+impl BackupService {
+ pub(crate) async fn serve(&self, stop: CancellationToken, drain: CancellationToken) {
+ let mut leader = None;
+ let mut migration_ready = false;
+ while !stop.is_cancelled() && !drain.is_cancelled() {
+ if !migration_ready {
+ migration_ready = match self.state.storage.inspect_data_migration().await {
+ Ok(status) => status.is_ready(),
+ Err(error) => {
+ log::warn!("[database-backup] waiting for data migration: {error}");
+ false
+ }
+ };
+ }
+ if migration_ready && leader.is_none() {
+ let acquired = std::fs::create_dir_all(&self.store.directory)
+ .and_then(|_| {
+ OpenOptions::new()
+ .create(true)
+ .truncate(false)
+ .read(true)
+ .write(true)
+ .open(self.store.directory.join("worker.lock"))
+ })
+ .ok()
+ .filter(|file| fs2::FileExt::try_lock_exclusive(file).is_ok());
+ if let Some(file) = acquired {
+ match self.store.recover().await {
+ Ok(()) => leader = Some(file),
+ Err(error) => log::error!("[database-backup] recovery failed: {error}"),
+ }
+ }
+ }
+ if migration_ready && leader.is_some() && !drain.is_cancelled() {
+ if let Err(error) = self.tick(&stop).await {
+ log::error!("[database-backup] scheduler failed: {error}");
+ }
+ }
+ tokio::select! { _ = stop.cancelled() => break, _ = drain.cancelled() => break, _ = tokio::time::sleep(Duration::from_secs(2)) => {} }
+ }
+ // Dropping the locked handle transfers leadership, including after an unclean process exit.
+ drop(leader);
+ }
+
+ async fn tick(&self, stop: &CancellationToken) -> Result<(), String> {
+ self.store.heartbeat().await?;
+ self.store.enqueue_due().await?;
+ let Some(job) = self.store.claim().await? else {
+ return Ok(());
+ };
+ let state = Arc::new(AppState::new_with_plugin_dir(
+ self.state.storage.clone(),
+ self.state.storage.data_dir().join("plugins"),
+ ));
+ let cancellation = stop.child_token();
+ if self.store.cancelled(job.run.id.clone()).await? {
+ cancellation.cancel();
+ }
+ let (progress, receiver) = watch::channel(job.run.clone());
+ let service = self.clone();
+ let worker_state = state.clone();
+ let worker_cancel = cancellation.clone();
+ let run_id = job.run.id.clone();
+ let fallback = job.run.clone();
+ let runtime = tokio::runtime::Handle::current();
+ // SQL formatting and compression perform synchronous writes; keep them off async worker threads.
+ let mut worker = tokio::task::spawn_blocking(move || {
+ runtime.block_on(service.execute(job, worker_state, worker_cancel, progress))
+ });
+ let mut run = loop {
+ tokio::select! {
+ result = &mut worker => break match result {
+ Ok(run) => run,
+ Err(error) => { let mut run = receiver.borrow().clone(); run.status = "failed".into(); run.error = Some(format!("Backup worker failed: {error}")); break run; }
+ },
+ _ = tokio::time::sleep(Duration::from_millis(500)) => {
+ if stop.is_cancelled() || self.store.cancelled(run_id.clone()).await.unwrap_or(true) {
+ cancellation.cancel();
+ database_export::set_export_cancelled(&run_id).await;
+ }
+ if let Err(error) = self.store.heartbeat().await { log::error!("[database-backup] heartbeat failed: {error}"); cancellation.cancel(); }
+ let latest = receiver.borrow().clone();
+ if let Err(error) = self.store.progress(latest).await { log::error!("[database-backup] progress persistence failed: {error}"); cancellation.cancel(); }
+ }
+ }
+ };
+ database_export::clear_export_cancelled(&run_id).await;
+ state.shutdown(Duration::from_secs(3)).await;
+ if run.status != "success" {
+ if let Err(error) = self.cleanup(&mut run).await {
+ run.error = Some(format!(
+ "{}; partial files retained: {error}",
+ run.error.as_deref().unwrap_or("Backup cancelled")
+ ));
+ }
+ }
+ run.completed_at = Some(Utc::now().to_rfc3339());
+ let success = run.status == "success";
+ self.store.finish(run).await?;
+ if success {
+ let snapshot = self.store.snapshot().await?;
+ if let Some(schedule) = snapshot.schedules.iter().find(|s| Some(&s.id) == fallback.schedule_id.as_ref()) {
+ let ids = snapshot
+ .runs
+ .iter()
+ .filter(|r| r.schedule_id.as_ref() == Some(&schedule.id) && r.status == "success")
+ .skip(schedule.retention_count)
+ .map(|r| r.id.clone())
+ .collect();
+ self.delete_runs(ids).await?;
+ }
+ }
+ Ok(())
+ }
+
+ async fn execute(
+ &self,
+ mut job: Job,
+ state: Arc,
+ stop: CancellationToken,
+ progress: watch::Sender,
+ ) -> BackupRun {
+ let result = self.export_job(&mut job, &state, &stop, &progress).await;
+ job.run.status = if stop.is_cancelled() {
+ "cancelled"
+ } else if result.is_ok() {
+ "success"
+ } else {
+ "failed"
+ }
+ .into();
+ job.run.error = result.err();
+ if job.run.status == "success" {
+ job.run.progress_percent = 100.0;
+ }
+ job.run
+ }
+
+ async fn export_job(
+ &self,
+ job: &mut Job,
+ state: &Arc,
+ stop: &CancellationToken,
+ progress: &watch::Sender,
+ ) -> Result<(), String> {
+ check_cancel(stop)?;
+ job.config.validate()?;
+ self.validate_destination(&job.config.destination_directory)?;
+ let connection = state
+ .storage
+ .load_connections()
+ .await?
+ .into_iter()
+ .find(|c| c.id == job.config.connection_id)
+ .ok_or("Saved backup connection is unavailable")?;
+ if !matches!(connection.db_type, DatabaseType::Mysql | DatabaseType::Postgres) {
+ return Err("This connection does not support consistent scheduled backups".into());
+ }
+ if !connection.save_password || connection.one_time {
+ return Err("Unattended backups require a saved connection and saved credentials".into());
+ }
+ let postgres = connection.db_type == DatabaseType::Postgres;
+ job.run.connection_name = connection.name.clone();
+ state.configs.write().await.insert(connection.id.clone(), connection);
+ check_cancel(stop)?;
+ let available: Vec =
+ schema::list_databases_core(state, &job.config.connection_id).await?.into_iter().map(|d| d.name).collect();
+ let databases = if job.config.databases.is_empty() {
+ available.iter().filter(|name| !system_database(name, postgres)).cloned().collect::>()
+ } else {
+ for name in &job.config.databases {
+ if !available.contains(name) {
+ return Err(format!("Configured backup database is unavailable: {name}"));
+ }
+ }
+ job.config.databases.clone()
+ };
+ if databases.is_empty() {
+ return Err("No databases are available for this backup".into());
+ }
+ let mut sensitive = true;
+ if !postgres && job.config.table_filter_mode != "all" {
+ if let Ok(result) = query::execute_sql_statement(
+ state,
+ &job.config.connection_id,
+ "",
+ "SHOW VARIABLES LIKE 'lower_case_table_names'",
+ None,
+ None,
+ )
+ .await
+ {
+ sensitive = !result
+ .rows
+ .first()
+ .and_then(|r| r.get(1))
+ .is_some_and(|v| matches!(v.as_str(), Some("1" | "2")) || matches!(v.as_i64(), Some(1 | 2)));
+ }
+ }
+ let directory = Path::new(&job.config.destination_directory).to_path_buf();
+ let output = if let Some(pattern) = &job.directory_pattern {
+ directory.join(render_template(pattern, &job.run, "", &job.time_zone, true)?)
+ } else {
+ directory
+ };
+ let mut included_count = 0;
+ for (db_index, database) in databases.iter().enumerate() {
+ check_cancel(stop)?;
+ let schemas = if postgres {
+ schema::list_schemas_core(state, &job.config.connection_id, database)
+ .await?
+ .into_iter()
+ .filter(|s| s != "information_schema" && !s.starts_with("pg_"))
+ .collect::>()
+ } else {
+ vec![database.clone()]
+ };
+ if schemas.is_empty() {
+ return Err(format!("Database {database} has no exportable schemas"));
+ }
+ // Metadata discovery may be slow; finish it before starting the snapshot idle timeout.
+ let mut targets = Vec::new();
+ for schema_name in &schemas {
+ check_cancel(stop)?;
+ let mut selected_tables = Vec::new();
+ let mut excluded_tables = Vec::new();
+ if job.config.table_filter_mode != "all" {
+ let tables = schema::list_tables_core(
+ state,
+ &job.config.connection_id,
+ database,
+ schema_name,
+ None,
+ None,
+ None,
+ None,
+ None,
+ )
+ .await?;
+ for table in &tables {
+ let matched = job
+ .config
+ .table_patterns
+ .iter()
+ .any(|p| matches_pattern(p, &table.name, database, schema_name, sensitive));
+ if matched {
+ if job.config.table_filter_mode == "include" {
+ selected_tables.push(table.name.clone());
+ } else {
+ excluded_tables.push(table.name.clone());
+ }
+ }
+ }
+ let included = if job.config.table_filter_mode == "include" {
+ selected_tables.len()
+ } else {
+ tables.len() - excluded_tables.len()
+ };
+ included_count += included;
+ if included == 0 {
+ continue;
+ }
+ }
+ targets.push((schema_name.clone(), selected_tables, excluded_tables));
+ }
+ if targets.is_empty() {
+ continue;
+ }
+ check_cancel(stop)?;
+ let snapshot = database_export::begin_database_backup_snapshot_core_for_export(
+ state,
+ &job.config.connection_id,
+ database,
+ Some(&job.run.id),
+ )
+ .await?;
+ let result = async {
+ for (schema_index, (schema_name, selected_tables, excluded_tables)) in targets.iter().enumerate() {
+ check_cancel(stop)?;
+ let stem = if schemas.len() > 1 { format!("{database}.{schema_name}") } else { database.clone() };
+ let name = render_template(
+ job.config.file_name_pattern.as_deref().unwrap_or(DEFAULT_FILE),
+ &job.run,
+ &stem,
+ &job.time_zone,
+ false,
+ )?;
+ let extension = if job.config.output_compression == "gzip" { "sql.gz" } else { "sql" };
+ let path = output.join(format!("{name}.{extension}"));
+ check_output_ancestors(&path, Path::new(&job.config.destination_directory))?;
+ if path.try_exists().map_err(|e| e.to_string())? {
+ return Err(format!("Backup file already exists: {}", path.display()));
+ }
+ if job.run.files.iter().any(|f| Path::new(&f.file_path) == path) {
+ return Err("Backup filename template produced duplicate paths".into());
+ }
+ let file = BackupFile {
+ database: database.clone(),
+ schema: schema_name.clone(),
+ display_name: stem,
+ file_path: path.to_string_lossy().into_owned(),
+ owned: false,
+ };
+ job.run.files.push(file.clone());
+ self.store.progress(job.run.clone()).await?;
+ progress.send_replace(job.run.clone());
+ let request = DatabaseExportRequest {
+ export_id: job.run.id.clone(),
+ connection_id: job.config.connection_id.clone(),
+ database: database.clone(),
+ schema: schema_name.clone(),
+ file_path: file.file_path,
+ selected_tables: selected_tables.clone(),
+ excluded_tables: excluded_tables.clone(),
+ include_structure: job.config.include_structure,
+ include_data: job.config.include_data,
+ include_objects: job.config.include_objects,
+ include_create_database: false,
+ drop_table_if_exists: job.config.drop_table_if_exists,
+ omit_auto_increment: false,
+ fail_on_error: true,
+ prevent_overwrite: true,
+ output_compression: if job.config.output_compression == "gzip" {
+ DatabaseExportOutputCompression::Gzip
+ } else {
+ DatabaseExportOutputCompression::None
+ },
+ snapshot_session_id: Some(snapshot.session_id.clone()),
+ batch_size: 1000,
+ split_max_mb: None,
+ };
+ let terminal_cancelled = std::sync::atomic::AtomicBool::new(false);
+ let created = Arc::new(std::sync::atomic::AtomicBool::new(false));
+ let result = database_export::track_backup_destination(
+ path,
+ created.clone(),
+ database_export::export_database_sql_core(state, &request, |event| {
+ if matches!(event.status, ExportStatus::Cancelled) {
+ terminal_cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
+ }
+ let mut run = job.run.clone();
+ if let Some(file) = run.files.last_mut() {
+ file.owned = created.load(std::sync::atomic::Ordering::Relaxed);
+ }
+ let fraction = if matches!(event.status, ExportStatus::Done) {
+ 1.0
+ } else if event.total_objects > 0 {
+ event.object_index as f64 / event.total_objects as f64
+ } else {
+ 0.0
+ };
+ run.progress_percent = ((db_index as f64
+ + (schema_index as f64 + fraction) / targets.len() as f64)
+ / databases.len() as f64
+ * 100.0)
+ .min(99.0);
+ progress.send_replace(run);
+ }),
+ )
+ .await;
+ if created.load(std::sync::atomic::Ordering::Relaxed) {
+ if let Some(file) = job.run.files.last_mut() {
+ file.owned = true;
+ }
+ } else {
+ // Preparation failures and external writers grant no file deletion authority.
+ job.run.files.pop();
+ }
+ result?;
+ if terminal_cancelled.load(std::sync::atomic::Ordering::Relaxed) {
+ stop.cancel();
+ }
+ check_cancel(stop)?;
+ job.run.progress_percent = progress.borrow().progress_percent;
+ }
+ Ok::<_, String>(())
+ }
+ .await;
+ let rollback = query::rollback_manual_transaction(state, &snapshot.session_id).await;
+ result?;
+ rollback?;
+ }
+ if job.config.table_filter_mode != "all" && included_count == 0 {
+ return Err("No tables matched the backup table scope".into());
+ }
+ Ok(())
+ }
+}
+
+fn check_cancel(stop: &CancellationToken) -> Result<(), String> {
+ if stop.is_cancelled() {
+ Err("Backup cancelled".into())
+ } else {
+ Ok(())
+ }
+}
+
+fn system_database(name: &str, postgres: bool) -> bool {
+ let name = name.to_ascii_lowercase();
+ if postgres {
+ matches!(name.as_str(), "template0" | "template1")
+ } else {
+ matches!(name.as_str(), "information_schema" | "mysql" | "performance_schema" | "sys")
+ }
+}
+
+fn check_output_ancestors(path: &Path, root: &Path) -> Result<(), String> {
+ let relative = path.strip_prefix(root).map_err(|_| "Backup output escaped destination")?;
+ let mut current = root.to_path_buf();
+ for component in relative.components() {
+ if !matches!(component, std::path::Component::Normal(_)) {
+ return Err("Invalid backup output path".into());
+ }
+ current.push(component);
+ match std::fs::symlink_metadata(¤t) {
+ Ok(metadata) if metadata.file_type().is_symlink() => {
+ return Err("Backup output contains a symbolic link".into())
+ }
+ Err(error) if error.kind() != std::io::ErrorKind::NotFound => return Err(error.to_string()),
+ _ => {}
+ }
+ }
+ Ok(())
+}
diff --git a/crates/dbx-core/src/scheduled_backup/mod.rs b/crates/dbx-core/src/scheduled_backup/mod.rs
new file mode 100644
index 0000000000..f0b87bb92f
--- /dev/null
+++ b/crates/dbx-core/src/scheduled_backup/mod.rs
@@ -0,0 +1,24 @@
+//! Durable, UI-independent database backup scheduling shared by desktop and Web.
+mod engine;
+mod models;
+mod service;
+mod store;
+#[cfg(test)]
+mod tests;
+
+pub use models::{BackupConfig, BackupFile, BackupRun, BackupSchedule, Migration, RunRequest};
+pub use service::{BackupCommand, BackupService};
+pub use store::{BackupSnapshot, BackupStore};
+
+/// Metadata and export command chains nest very large async futures - a single
+/// frame can reach 60-150 KiB - which does not fit into tokio's default 2 MiB
+/// worker stack. Every runtime that drives them (desktop, background worker and
+/// Web server) has to reserve the same roomy stack, otherwise the process dies
+/// with `fatal runtime error: stack overflow` instead of reporting an error.
+pub const WORKER_STACK_SIZE: usize = 16 * 1024 * 1024;
+
+/// Builds the multi-threaded runtime used by processes that run metadata and
+/// backup work, on a stack that can hold those nested futures.
+pub fn worker_runtime() -> std::io::Result {
+ tokio::runtime::Builder::new_multi_thread().enable_all().thread_stack_size(WORKER_STACK_SIZE).build()
+}
diff --git a/crates/dbx-core/src/scheduled_backup/models.rs b/crates/dbx-core/src/scheduled_backup/models.rs
new file mode 100644
index 0000000000..de37ac2937
--- /dev/null
+++ b/crates/dbx-core/src/scheduled_backup/models.rs
@@ -0,0 +1,297 @@
+use chrono::{DateTime, Datelike, Duration, LocalResult, NaiveTime, TimeZone, Utc};
+use chrono_tz::Tz;
+use serde::{Deserialize, Serialize};
+
+pub const DEFAULT_DIRECTORY: &str = "dbx-backup__{schedule}__{timestamp}__{runId}";
+pub const DEFAULT_FILE: &str = "dbx-backup__{schedule}__{timestamp}__{database}__{runId}";
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BackupConfig {
+ pub connection_id: String,
+ #[serde(default)]
+ pub databases: Vec,
+ #[serde(default = "all")]
+ pub table_filter_mode: String,
+ #[serde(default)]
+ pub table_patterns: Vec,
+ pub destination_directory: String,
+ pub include_structure: bool,
+ pub include_data: bool,
+ pub include_objects: bool,
+ #[serde(default)]
+ pub drop_table_if_exists: bool,
+ #[serde(default = "none")]
+ pub output_compression: String,
+ #[serde(default)]
+ pub file_name_pattern: Option,
+}
+
+fn all() -> String {
+ "all".into()
+}
+fn none() -> String {
+ "none".into()
+}
+fn utc() -> String {
+ "UTC".into()
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BackupSchedule {
+ pub id: String,
+ pub name: String,
+ pub enabled: bool,
+ #[serde(flatten)]
+ pub config: BackupConfig,
+ pub frequency: String,
+ pub interval_hours: u32,
+ pub time_of_day: String,
+ pub weekday: u32,
+ pub retention_count: usize,
+ #[serde(default)]
+ pub run_directory_pattern: Option,
+ #[serde(default = "utc")]
+ pub time_zone: String,
+ pub created_at: String,
+ pub updated_at: String,
+ #[serde(default)]
+ pub next_run_at: String,
+ #[serde(default)]
+ pub last_run_at: Option,
+ #[serde(default)]
+ pub last_run_status: Option,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BackupFile {
+ pub database: String,
+ pub schema: String,
+ pub display_name: String,
+ pub file_path: String,
+ #[serde(default = "owned_by_legacy_run")]
+ pub owned: bool,
+}
+
+fn owned_by_legacy_run() -> bool {
+ true
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BackupRun {
+ pub id: String,
+ #[serde(default)]
+ pub schedule_id: Option,
+ pub schedule_name: String,
+ #[serde(default)]
+ pub display_name: Option,
+ pub connection_id: String,
+ pub connection_name: String,
+ #[serde(default)]
+ pub destination_directory: String,
+ pub trigger: String,
+ pub source: String,
+ pub status: String,
+ pub started_at: String,
+ #[serde(default)]
+ pub completed_at: Option,
+ #[serde(default)]
+ pub files: Vec,
+ #[serde(default)]
+ pub progress_percent: f64,
+ #[serde(default)]
+ pub error: Option,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct RunRequest {
+ pub schedule_id: Option,
+ pub config: Option,
+ pub display_name: Option,
+ pub time_zone: Option,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub(crate) struct Job {
+ pub run: BackupRun,
+ pub config: BackupConfig,
+ pub directory_pattern: Option,
+ pub time_zone: String,
+}
+
+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct Migration {
+ #[serde(default)]
+ pub schedules: Vec,
+ #[serde(default)]
+ pub runs: Vec,
+}
+
+impl BackupConfig {
+ pub fn validate(&self) -> Result<(), String> {
+ bounded_text(&self.connection_id, 256, "Connection")?;
+ bounded_text(&self.destination_directory, 4096, "Backup directory")?;
+ if !std::path::Path::new(&self.destination_directory).is_absolute() {
+ return Err("Backup directory must be absolute".into());
+ }
+ if !self.include_structure && !self.include_data && !self.include_objects {
+ return Err("Select backup contents".into());
+ }
+ if !matches!(self.output_compression.as_str(), "none" | "gzip") {
+ return Err("Unsupported backup compression".into());
+ }
+ if !matches!(self.table_filter_mode.as_str(), "all" | "include" | "exclude") {
+ return Err("Unsupported backup table filter".into());
+ }
+ if self.databases.len() > 10_000 || self.table_patterns.len() > 1000 {
+ return Err("Too many backup targets or patterns".into());
+ }
+ for value in self.databases.iter().chain(self.table_patterns.iter()) {
+ bounded_text(value, 1024, "Backup target")?;
+ }
+ if self.table_filter_mode != "all" && self.table_patterns.is_empty() {
+ return Err("Select backup table patterns".into());
+ }
+ validate_template(self.file_name_pattern.as_deref().unwrap_or(DEFAULT_FILE), false)
+ }
+}
+
+impl BackupSchedule {
+ pub fn validate(&self) -> Result<(), String> {
+ self.config.validate()?;
+ bounded_text(&self.id, 256, "Schedule ID")?;
+ bounded_text(&self.name, 256, "Schedule name")?;
+ if !matches!(self.frequency.as_str(), "hourly" | "daily" | "weekly")
+ || !(1..=168).contains(&self.interval_hours)
+ || self.weekday > 6
+ || !(1..=100).contains(&self.retention_count)
+ {
+ return Err("Invalid backup schedule".into());
+ }
+ NaiveTime::parse_from_str(&self.time_of_day, "%H:%M").map_err(|_| "Invalid backup time")?;
+ self.time_zone.parse::().map_err(|_| "Invalid backup time zone")?;
+ validate_template(self.run_directory_pattern.as_deref().unwrap_or(DEFAULT_DIRECTORY), true)
+ }
+
+ pub fn next_after(&self, after: DateTime) -> Result, String> {
+ if self.frequency == "hourly" {
+ return Ok(after + Duration::hours(self.interval_hours.clamp(1, 168) as i64));
+ }
+ let tz: Tz = self.time_zone.parse().map_err(|_| "Invalid backup time zone")?;
+ let time = NaiveTime::parse_from_str(&self.time_of_day, "%H:%M").map_err(|_| "Invalid backup time")?;
+ let today = after.with_timezone(&tz).date_naive();
+ for offset in 0..15 {
+ let date = today + Duration::days(offset);
+ if self.frequency == "weekly" && date.weekday().num_days_from_sunday() != self.weekday {
+ continue;
+ }
+ // A repeated wall-clock time runs only at its first occurrence.
+ // A nonexistent spring-forward time runs at the first valid minute after the gap.
+ for minute in 0..180 {
+ let local = date.and_time(time) + Duration::minutes(minute);
+ let candidate = match tz.from_local_datetime(&local) {
+ LocalResult::Single(value) => Some(value),
+ LocalResult::Ambiguous(first, _) => Some(first),
+ LocalResult::None => None,
+ };
+ if let Some(value) = candidate {
+ if value.with_timezone(&Utc) > after {
+ return Ok(value.with_timezone(&Utc));
+ }
+ break;
+ }
+ }
+ }
+ Err("Unable to calculate next backup time".into())
+ }
+}
+
+pub(crate) fn bounded_text(value: &str, max: usize, label: &str) -> Result<(), String> {
+ if value.trim().is_empty() || value.len() > max || value.chars().any(char::is_control) {
+ Err(format!("Invalid {label}"))
+ } else {
+ Ok(())
+ }
+}
+
+pub(crate) fn validate_template(value: &str, directory: bool) -> Result<(), String> {
+ bounded_text(value, 1024, "backup name template")?;
+ if directory {
+ let drive_prefix = value.as_bytes().get(1) == Some(&b':') && value.as_bytes()[0].is_ascii_alphabetic();
+ if value.starts_with(['/', '\\'])
+ || value.ends_with(['/', '\\'])
+ || drive_prefix
+ || value.split(['/', '\\']).any(|part| part == "." || part == "..")
+ {
+ return Err("Invalid backup directory template".into());
+ }
+ return Ok(());
+ }
+ if value.trim() != value
+ || value.starts_with(['/', '\\'])
+ || value.ends_with(['/', '\\', '.', ' '])
+ || value.contains([':', '*', '?', '"', '<', '>', '|'])
+ || (!directory && value.contains(['/', '\\']))
+ || value.split(['/', '\\']).any(|part| part.is_empty() || part == "." || part == "..")
+ {
+ return Err("Invalid backup name template".into());
+ }
+ Ok(())
+}
+
+pub(crate) fn segment(value: &str) -> String {
+ let cleaned: String =
+ value.chars().map(|c| if c.is_control() || "\\/:*?\"<>|".contains(c) { '_' } else { c }).collect();
+ let cleaned = cleaned.trim().trim_end_matches(['.', ' ']);
+ let name = if cleaned.is_empty() { "database" } else { cleaned };
+ let first = name.split('.').next().unwrap_or(name).to_ascii_uppercase();
+ if matches!(first.as_str(), "CON" | "PRN" | "AUX" | "NUL")
+ || (first.len() == 4
+ && (first.starts_with("COM") || first.starts_with("LPT"))
+ && matches!(first.as_bytes()[3], b'1'..=b'9'))
+ {
+ format!("{name}_")
+ } else {
+ name.into()
+ }
+}
+
+pub(crate) fn render_template(
+ template: &str,
+ run: &BackupRun,
+ database: &str,
+ time_zone: &str,
+ directory: bool,
+) -> Result {
+ validate_template(template, directory)?;
+ let tz: Tz = time_zone.parse().map_err(|_| "Invalid backup time zone")?;
+ let started =
+ DateTime::parse_from_rfc3339(&run.started_at).map_err(|_| "Invalid backup start time")?.with_timezone(&tz);
+ let timestamp = started.format(if directory { "%Y%m%d%H%M%S" } else { "%Y%m%d-%H%M%S" }).to_string();
+ let rendered = template
+ .replace("{schedule}", &segment(&run.schedule_name))
+ .replace("{date}", &started.format("%Y%m%d").to_string())
+ .replace("{timestamp}", ×tamp)
+ .replace("{runId}", &run.id.chars().take(8).collect::());
+ if directory {
+ Ok(rendered.split(['/', '\\']).filter(|part| !part.is_empty()).map(segment).collect::>().join("/"))
+ } else if template.contains("{database}") {
+ Ok(segment(&rendered.replace("{database}", &segment(database))))
+ } else {
+ Ok(format!("{}__{}", segment(&rendered), segment(database)))
+ }
+}
+
+pub(crate) fn matches_pattern(pattern: &str, table: &str, database: &str, schema: &str, sensitive: bool) -> bool {
+ let expression = format!("^{}$", regex::escape(pattern).replace("\\*", ".*").replace("\\?", "."));
+ regex::RegexBuilder::new(&expression).case_insensitive(!sensitive).build().is_ok_and(|re| {
+ re.is_match(table)
+ || (!schema.is_empty() && re.is_match(&format!("{schema}.{table}")))
+ || (!database.is_empty() && !schema.is_empty() && re.is_match(&format!("{database}.{schema}.{table}")))
+ })
+}
diff --git a/crates/dbx-core/src/scheduled_backup/service.rs b/crates/dbx-core/src/scheduled_backup/service.rs
new file mode 100644
index 0000000000..f5b936fef4
--- /dev/null
+++ b/crates/dbx-core/src/scheduled_backup/service.rs
@@ -0,0 +1,212 @@
+use serde::Deserialize;
+use serde_json::Value;
+use std::{
+ path::{Component, Path, PathBuf},
+ sync::Arc,
+};
+use tokio_util::sync::CancellationToken;
+
+use super::{models::*, BackupStore};
+use crate::{connection::AppState, database_export::record_export_destination_identity};
+
+#[derive(Debug, Deserialize)]
+#[serde(tag = "action", rename_all = "camelCase")]
+pub enum BackupCommand {
+ Snapshot,
+ Preview { schedule: BackupSchedule },
+ Save { schedule: BackupSchedule },
+ DeleteSchedule { id: String },
+ Migrate { migration: Migration },
+ Run { request: RunRequest },
+ Cancel { id: String },
+ Rename { id: String, name: String },
+ DeleteRuns { ids: Vec },
+ File { id: String, index: usize },
+}
+
+#[derive(Clone)]
+pub struct BackupService {
+ pub(crate) state: Arc,
+ pub store: BackupStore,
+ pub(crate) root: Option,
+}
+
+impl BackupService {
+ /// Web callers must supply a server-owned root, never one from the request.
+ pub fn new(state: Arc, data_dir: &Path, root: Option) -> Self {
+ Self { state, store: BackupStore::new(data_dir), root }
+ }
+
+ pub fn start(&self, stop: CancellationToken) -> tokio::task::JoinHandle<()> {
+ self.start_with_drain(stop, CancellationToken::new())
+ }
+
+ /// Draining finishes the active job before releasing leadership; stopping cancels it.
+ pub fn start_with_drain(&self, stop: CancellationToken, drain: CancellationToken) -> tokio::task::JoinHandle<()> {
+ let service = self.clone();
+ tokio::spawn(async move { service.serve(stop, drain).await })
+ }
+
+ pub async fn command(&self, command: BackupCommand) -> Result {
+ match command {
+ BackupCommand::File { id, index } => {
+ Ok(Value::String(self.file(&id, index).await?.to_string_lossy().into_owned()))
+ }
+ BackupCommand::Preview { schedule } => {
+ Ok(Value::String(schedule.next_after(chrono::Utc::now())?.to_rfc3339()))
+ }
+ BackupCommand::Snapshot => {
+ let mut value = serde_json::to_value(self.store.snapshot().await?).map_err(|e| e.to_string())?;
+ value["destinationRoot"] = serde_json::to_value(&self.root).map_err(|e| e.to_string())?;
+ Ok(value)
+ }
+ BackupCommand::Save { schedule } => {
+ schedule.validate()?;
+ self.validate_destination(&schedule.config.destination_directory)?;
+ let old = self.store.snapshot().await?.schedules.into_iter().find(|s| s.id == schedule.id);
+ if old.is_none_or(|s| s.config.destination_directory != schedule.config.destination_directory) {
+ record_export_destination_identity(&self.state, Path::new(&schedule.config.destination_directory))
+ .await?;
+ }
+ serde_json::to_value(self.store.save_schedule(schedule).await?).map_err(|e| e.to_string())
+ }
+ BackupCommand::DeleteSchedule { id } => {
+ self.store.delete_schedule(id).await?;
+ Ok(Value::Null)
+ }
+ BackupCommand::Migrate { migration } => {
+ if self.store.snapshot().await?.migrated {
+ return Ok(Value::Null);
+ }
+ // Browser-local history must not grant authority over arbitrary server files.
+ if self.root.is_some() && (!migration.schedules.is_empty() || !migration.runs.is_empty()) {
+ return Err("Desktop backups cannot be imported into a Web server automatically".into());
+ }
+ self.store.migrate(migration).await?;
+ Ok(Value::Null)
+ }
+ BackupCommand::Run { request } => {
+ if let Some(config) = &request.config {
+ self.validate_destination(&config.destination_directory)?;
+ }
+ serde_json::to_value(self.store.enqueue(request).await?).map_err(|e| e.to_string())
+ }
+ BackupCommand::Cancel { id } => Ok(Value::Bool(self.store.cancel(id).await?)),
+ BackupCommand::Rename { id, name } => {
+ self.store.rename(id, name).await?;
+ Ok(Value::Null)
+ }
+ BackupCommand::DeleteRuns { ids } => {
+ self.delete_runs(ids).await?;
+ Ok(Value::Null)
+ }
+ }
+ }
+
+ pub(crate) fn validate_destination(&self, path: &str) -> Result {
+ let path = Path::new(path);
+ if !path.is_absolute() || path.components().any(|c| matches!(c, Component::ParentDir)) {
+ return Err("Backup directory must be an absolute path without traversal".into());
+ }
+ let resolved = path.canonicalize().map_err(|e| format!("Backup directory is unavailable: {e}"))?;
+ if !resolved.is_dir() {
+ return Err("Backup destination is not a directory".into());
+ }
+ if let Some(root) = &self.root {
+ let root = root.canonicalize().map_err(|e| format!("Server backup root is unavailable: {e}"))?;
+ if !resolved.starts_with(root) {
+ return Err("Backup directory is outside the server backup root".into());
+ }
+ }
+ Ok(resolved)
+ }
+
+ pub async fn file(&self, id: &str, index: usize) -> Result {
+ let snapshot = self.store.snapshot().await?;
+ let run =
+ snapshot.runs.iter().find(|r| r.id == id && r.status == "success").ok_or("Completed backup not found")?;
+ let file = run.files.get(index).ok_or("Backup file not found")?;
+ self.checked_file(run, Path::new(&file.file_path)).await
+ }
+
+ async fn checked_file(&self, run: &BackupRun, path: &Path) -> Result {
+ let root = self.validate_destination(&run.destination_directory)?;
+ crate::database_export::verify_export_destination_identity(&self.state, Path::new(&run.destination_directory))
+ .await?;
+ let metadata = std::fs::symlink_metadata(path).map_err(|e| e.to_string())?;
+ if !metadata.is_file() || metadata.file_type().is_symlink() {
+ return Err("Backup file is not a regular file".into());
+ }
+ let resolved = path.canonicalize().map_err(|e| e.to_string())?;
+ if resolved == root || !resolved.starts_with(root) {
+ return Err("Backup file escaped its destination".into());
+ }
+ let name = resolved.file_name().unwrap_or_default().to_string_lossy().to_ascii_lowercase();
+ if !name.ends_with(".sql") && !name.ends_with(".sql.gz") {
+ return Err("Not a SQL backup file".into());
+ }
+ Ok(resolved)
+ }
+
+ pub(crate) async fn cleanup(&self, run: &mut BackupRun) -> Result<(), String> {
+ let others = self.store.snapshot().await?.runs;
+ let mut retained = Vec::new();
+ let mut errors = Vec::new();
+ for file in &run.files {
+ if others.iter().any(|r| r.id != run.id && r.files.iter().any(|f| f.file_path == file.file_path)) {
+ continue;
+ }
+ let path = Path::new(&file.file_path);
+ match std::fs::symlink_metadata(path) {
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
+ _ => {}
+ }
+ if !file.owned {
+ retained.push(file.clone());
+ errors.push("File ownership could not be confirmed after interruption; inspect the file before removing it manually".into());
+ continue;
+ }
+ match self.checked_file(run, path).await {
+ Ok(path) => {
+ if let Err(e) = tokio::fs::remove_file(path).await {
+ if e.kind() != std::io::ErrorKind::NotFound {
+ retained.push(file.clone());
+ errors.push(e.to_string());
+ }
+ }
+ }
+ Err(error) => {
+ retained.push(file.clone());
+ errors.push(error);
+ }
+ }
+ }
+ run.files = retained;
+ if errors.is_empty() {
+ Ok(())
+ } else {
+ Err(errors.join("; "))
+ }
+ }
+
+ pub(crate) async fn delete_runs(&self, ids: Vec) -> Result<(), String> {
+ if ids.len() > 1000 {
+ return Err("Too many backups selected".into());
+ }
+ let mut errors = Vec::new();
+ for mut run in self.store.mark_deleting(ids).await? {
+ if let Err(error) = self.cleanup(&mut run).await {
+ run.error = Some(format!("Backup cleanup failed: {error}"));
+ self.store.deletion_failed(run).await?;
+ errors.push(error);
+ continue;
+ }
+ self.store.remove_run(run.id).await?;
+ }
+ if errors.is_empty() {
+ Ok(())
+ } else {
+ Err(errors.join("; "))
+ }
+ }
+}
diff --git a/crates/dbx-core/src/scheduled_backup/store.rs b/crates/dbx-core/src/scheduled_backup/store.rs
new file mode 100644
index 0000000000..b97a2ed2b5
--- /dev/null
+++ b/crates/dbx-core/src/scheduled_backup/store.rs
@@ -0,0 +1,484 @@
+use std::path::{Path, PathBuf};
+
+use chrono::Utc;
+use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
+use serde::{de::DeserializeOwned, Deserialize, Serialize};
+
+use super::models::*;
+
+#[derive(Clone)]
+pub struct BackupStore {
+ pub(crate) directory: PathBuf,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BackupSnapshot {
+ pub schedules: Vec,
+ pub runs: Vec,
+ pub migrated: bool,
+ pub heartbeat: Option,
+}
+
+fn encode(value: &impl Serialize) -> Result {
+ serde_json::to_string(value).map_err(|e| e.to_string())
+}
+fn decode(value: String) -> Result {
+ serde_json::from_str(&value).map_err(|e| e.to_string())
+}
+fn sql_error(error: rusqlite::Error) -> String {
+ error.to_string()
+}
+
+fn schedules(conn: &Connection) -> Result, String> {
+ let mut statement = conn.prepare("SELECT payload FROM schedules ORDER BY id").map_err(sql_error)?;
+ let rows = statement.query_map([], |row| row.get::<_, String>(0)).map_err(sql_error)?;
+ rows.map(|row| decode(row.map_err(sql_error)?)).collect()
+}
+
+fn run_rows(conn: &Connection) -> Result, String> {
+ let mut statement =
+ conn.prepare("SELECT payload FROM runs ORDER BY created_at DESC, id DESC").map_err(sql_error)?;
+ let rows = statement.query_map([], |row| row.get::<_, String>(0)).map_err(sql_error)?;
+ rows.map(|row| decode(row.map_err(sql_error)?)).collect()
+}
+
+fn active(conn: &Connection, schedule_id: &str) -> Result {
+ conn.query_row(
+ "SELECT EXISTS(SELECT 1 FROM runs WHERE schedule_id=? AND state IN ('queued','running'))",
+ [schedule_id],
+ |r| r.get(0),
+ )
+ .map_err(sql_error)
+}
+
+fn insert_job(
+ conn: &Connection,
+ config: BackupConfig,
+ schedule: Option<&BackupSchedule>,
+ name: String,
+ trigger: &str,
+ time_zone: Option,
+) -> Result {
+ let count: usize = conn
+ .query_row("SELECT COUNT(*) FROM runs WHERE state IN ('queued','running')", [], |r| r.get(0))
+ .map_err(sql_error)?;
+ if count >= 100 {
+ return Err("Too many pending backups".into());
+ }
+ let run = BackupRun {
+ id: uuid::Uuid::new_v4().simple().to_string(),
+ schedule_id: schedule.map(|s| s.id.clone()),
+ schedule_name: name,
+ display_name: None,
+ connection_id: config.connection_id.clone(),
+ connection_name: String::new(),
+ destination_directory: config.destination_directory.clone(),
+ trigger: trigger.into(),
+ source: if schedule.is_some() { "scheduled" } else { "one-shot" }.into(),
+ status: "running".into(),
+ started_at: Utc::now().to_rfc3339(),
+ completed_at: None,
+ files: Vec::new(),
+ progress_percent: 0.0,
+ error: None,
+ };
+ let time_zone = schedule.map(|s| s.time_zone.clone()).or(time_zone).unwrap_or_else(|| "UTC".into());
+ time_zone.parse::().map_err(|_| "Invalid backup time zone")?;
+ let job = Job {
+ run: run.clone(),
+ config,
+ directory_pattern: schedule
+ .map(|s| s.run_directory_pattern.clone().unwrap_or_else(|| DEFAULT_DIRECTORY.into())),
+ time_zone,
+ };
+ conn.execute(
+ "INSERT INTO runs(id,schedule_id,state,payload,job,created_at) VALUES(?,?,'queued',?,?,?)",
+ params![run.id, run.schedule_id, encode(&run)?, encode(&job)?, run.started_at],
+ )
+ .map_err(sql_error)?;
+ Ok(run)
+}
+
+impl BackupStore {
+ pub fn new(data_dir: &Path) -> Self {
+ Self { directory: data_dir.join("database-backups") }
+ }
+
+ async fn access(
+ &self,
+ action: impl FnOnce(&mut Connection) -> Result + Send + 'static,
+ ) -> Result {
+ let dir = self.directory.clone();
+ tokio::task::spawn_blocking(move || {
+ std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
+ let mut conn = Connection::open(dir.join("state.db")).map_err(sql_error)?;
+ conn.busy_timeout(std::time::Duration::from_secs(5)).map_err(sql_error)?;
+ conn.execute_batch("PRAGMA journal_mode=WAL;
+ CREATE TABLE IF NOT EXISTS schedules(id TEXT PRIMARY KEY,payload TEXT NOT NULL);
+ CREATE TABLE IF NOT EXISTS runs(id TEXT PRIMARY KEY,schedule_id TEXT,state TEXT NOT NULL,payload TEXT NOT NULL,job TEXT,created_at TEXT NOT NULL,cancel INTEGER NOT NULL DEFAULT 0);
+ CREATE INDEX IF NOT EXISTS runs_pending ON runs(state,created_at);
+ CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY,value TEXT NOT NULL);").map_err(sql_error)?;
+ action(&mut conn)
+ }).await.map_err(|e| e.to_string())?
+ }
+
+ pub async fn snapshot(&self) -> Result {
+ self.access(|conn| {
+ let tx = conn.transaction().map_err(sql_error)?;
+ Ok(BackupSnapshot {
+ schedules: schedules(&tx)?,
+ runs: run_rows(&tx)?,
+ migrated: tx
+ .query_row("SELECT EXISTS(SELECT 1 FROM metadata WHERE key='migrated')", [], |r| r.get(0))
+ .map_err(sql_error)?,
+ heartbeat: tx
+ .query_row("SELECT value FROM metadata WHERE key='heartbeat'", [], |r| r.get(0))
+ .optional()
+ .map_err(sql_error)?,
+ })
+ })
+ .await
+ }
+
+ pub async fn save_schedule(&self, mut value: BackupSchedule) -> Result {
+ value.validate()?;
+ self.access(move |conn| {
+ let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
+ if active(&tx, &value.id)? {
+ return Err("Cannot edit a running backup schedule".into());
+ }
+ let old: Option = tx
+ .query_row("SELECT payload FROM schedules WHERE id=?", [&value.id], |r| r.get(0))
+ .optional()
+ .map_err(sql_error)?;
+ let now = Utc::now();
+ let mut timing_changed = true;
+ if let Some(old) = old {
+ let old: BackupSchedule = decode(old)?;
+ if old.updated_at != value.updated_at {
+ return Err("Backup schedule changed; reload before saving".into());
+ }
+ timing_changed = old.frequency != value.frequency
+ || old.interval_hours != value.interval_hours
+ || old.time_of_day != value.time_of_day
+ || old.weekday != value.weekday
+ || old.time_zone != value.time_zone
+ || (!old.enabled && value.enabled);
+ value.created_at = old.created_at;
+ value.last_run_at = old.last_run_at;
+ value.last_run_status = old.last_run_status;
+ value.next_run_at = old.next_run_at;
+ } else {
+ value.created_at = now.to_rfc3339();
+ value.last_run_at = None;
+ value.last_run_status = None;
+ }
+ if timing_changed {
+ value.next_run_at = value.next_after(now)?.to_rfc3339();
+ }
+ value.updated_at = now.to_rfc3339();
+ tx.execute(
+ "INSERT INTO schedules(id,payload) VALUES(?,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload",
+ params![value.id, encode(&value)?],
+ )
+ .map_err(sql_error)?;
+ tx.commit().map_err(sql_error)?;
+ Ok(value)
+ })
+ .await
+ }
+
+ pub async fn delete_schedule(&self, id: String) -> Result<(), String> {
+ self.access(move |conn| {
+ let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
+ if active(&tx, &id)? {
+ return Err("Cannot delete a running backup schedule".into());
+ }
+ tx.execute("DELETE FROM schedules WHERE id=?", [id]).map_err(sql_error)?;
+ tx.commit().map_err(sql_error)
+ })
+ .await
+ }
+
+ pub async fn migrate(&self, migration: Migration) -> Result<(), String> {
+ if migration.schedules.len() > 1000 || migration.runs.len() > 10_000 {
+ return Err("Backup migration is too large".into());
+ }
+ for schedule in &migration.schedules {
+ schedule.validate()?;
+ }
+ self.access(move |conn| {
+ let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
+ let done: bool = tx
+ .query_row("SELECT EXISTS(SELECT 1 FROM metadata WHERE key='migrated')", [], |r| r.get(0))
+ .map_err(sql_error)?;
+ if done {
+ return Ok(());
+ }
+ for mut schedule in migration.schedules {
+ if chrono::DateTime::parse_from_rfc3339(&schedule.next_run_at).is_err() {
+ schedule.next_run_at = schedule.next_after(Utc::now())?.to_rfc3339();
+ }
+ tx.execute(
+ "INSERT OR IGNORE INTO schedules(id,payload) VALUES(?,?)",
+ params![schedule.id, encode(&schedule)?],
+ )
+ .map_err(sql_error)?;
+ }
+ for mut run in migration.runs {
+ if run.status == "running" {
+ run.status = "failed".into();
+ run.error = Some("Backup interrupted before migration".into());
+ run.completed_at = Some(Utc::now().to_rfc3339());
+ }
+ if !matches!(run.status.as_str(), "success" | "failed" | "cancelled") {
+ return Err("Invalid migrated backup status".into());
+ }
+ tx.execute(
+ "INSERT OR IGNORE INTO runs(id,schedule_id,state,payload,created_at) VALUES(?,?,?,?,?)",
+ params![run.id, run.schedule_id, run.status, encode(&run)?, run.started_at],
+ )
+ .map_err(sql_error)?;
+ }
+ tx.execute("INSERT INTO metadata(key,value) VALUES('migrated','1')", []).map_err(sql_error)?;
+ tx.commit().map_err(sql_error)
+ })
+ .await
+ }
+
+ pub async fn enqueue(&self, request: RunRequest) -> Result {
+ self.access(move |conn| {
+ let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
+ let run = if let Some(id) = request.schedule_id {
+ if active(&tx, &id)? {
+ return Err("Backup schedule is already running".into());
+ }
+ let value: String =
+ tx.query_row("SELECT payload FROM schedules WHERE id=?", [&id], |r| r.get(0)).map_err(sql_error)?;
+ let schedule: BackupSchedule = decode(value)?;
+ insert_job(&tx, schedule.config.clone(), Some(&schedule), schedule.name.clone(), "manual", None)?
+ } else {
+ let config = request.config.ok_or("Backup configuration is required")?;
+ config.validate()?;
+ let name = request.display_name.unwrap_or_else(|| "Database backup".into());
+ bounded_text(&name, 256, "Backup name")?;
+ insert_job(&tx, config, None, name, "manual", request.time_zone)?
+ };
+ tx.commit().map_err(sql_error)?;
+ Ok(run)
+ })
+ .await
+ }
+
+ pub(crate) async fn enqueue_due(&self) -> Result<(), String> {
+ self.access(|conn| {
+ let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?;
+ let now = Utc::now();
+ for mut schedule in schedules(&tx)? {
+ if !schedule.enabled {
+ continue;
+ }
+ let pending: usize = tx
+ .query_row("SELECT COUNT(*) FROM runs WHERE state IN ('queued','running')", [], |r| r.get(0))
+ .map_err(sql_error)?;
+ if pending >= 100 {
+ break;
+ }
+ let due = chrono::DateTime::parse_from_rfc3339(&schedule.next_run_at)
+ .map_err(|_| "Invalid persisted backup time")?
+ .with_timezone(&Utc)
+ <= now;
+ if !schedule.enabled || !due || active(&tx, &schedule.id)? {
+ continue;
+ }
+ insert_job(&tx, schedule.config.clone(), Some(&schedule), schedule.name.clone(), "scheduled", None)?;
+ // Persist advancement together with the queue entry; a crash cannot replay every missed interval.
+ schedule.next_run_at = schedule.next_after(now)?.to_rfc3339();
+ tx.execute("UPDATE schedules SET payload=? WHERE id=?", params![encode(&schedule)?, schedule.id])
+ .map_err(sql_error)?;
+ }
+ tx.commit().map_err(sql_error)
+ })
+ .await
+ }
+
+ pub(crate) async fn claim(&self) -> Result