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) {
- + -
- - @@ -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, String> { + self.access(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?; + let payload: Option = tx + .query_row("SELECT job FROM runs WHERE state='queued' ORDER BY created_at,id LIMIT 1", [], |r| r.get(0)) + .optional() + .map_err(sql_error)?; + let job: Option = payload.map(decode).transpose()?; + if let Some(job) = &job { + tx.execute("UPDATE runs SET state='running' WHERE id=?", [&job.run.id]).map_err(sql_error)?; + } + tx.commit().map_err(sql_error)?; + Ok(job) + }) + .await + } + + pub async fn cancel(&self, id: String) -> Result { + self.access(move |conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?; + let queued: Option = tx + .query_row("SELECT payload FROM runs WHERE id=? AND state='queued'", [&id], |r| r.get(0)) + .optional() + .map_err(sql_error)?; + let accepted = if let Some(payload) = queued { + let mut run: BackupRun = decode(payload)?; + run.status = "cancelled".into(); + run.completed_at = Some(Utc::now().to_rfc3339()); + tx.execute( + "UPDATE runs SET state='cancelled',cancel=1,job=NULL,payload=? WHERE id=?", + params![encode(&run)?, id], + ) + .map_err(sql_error)? + > 0 + } else { + tx.execute("UPDATE runs SET cancel=1 WHERE id=? AND state='running'", [&id]).map_err(sql_error)? > 0 + }; + tx.commit().map_err(sql_error)?; + Ok(accepted) + }) + .await + } + + pub(crate) async fn cancelled(&self, id: String) -> Result { + self.access(move |conn| { + conn.query_row("SELECT cancel FROM runs WHERE id=?", [id], |r| r.get(0)).map_err(sql_error) + }) + .await + } + + pub(crate) async fn progress(&self, run: BackupRun) -> Result<(), String> { + self.access(move |conn| { + conn.execute("UPDATE runs SET payload=? WHERE id=? AND state='running'", params![encode(&run)?, run.id]) + .map_err(sql_error)?; + Ok(()) + }) + .await + } + + pub(crate) async fn finish(&self, run: BackupRun) -> Result<(), String> { + self.access(move |conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?; + tx.execute( + "UPDATE runs SET state=?,payload=?,job=NULL WHERE id=?", + params![run.status, encode(&run)?, run.id], + ) + .map_err(sql_error)?; + if let Some(id) = &run.schedule_id { + let payload: Option = tx + .query_row("SELECT payload FROM schedules WHERE id=?", [id], |r| r.get(0)) + .optional() + .map_err(sql_error)?; + if let Some(payload) = payload { + let mut schedule: BackupSchedule = decode(payload)?; + schedule.last_run_at = run.completed_at.clone(); + schedule.last_run_status = Some(run.status.clone()); + let now = Utc::now(); + if chrono::DateTime::parse_from_rfc3339(&schedule.next_run_at) + .is_ok_and(|t| t.with_timezone(&Utc) <= now) + { + schedule.next_run_at = schedule.next_after(now)?.to_rfc3339(); + } + tx.execute("UPDATE schedules SET payload=? WHERE id=?", params![encode(&schedule)?, id]) + .map_err(sql_error)?; + } + } + tx.commit().map_err(sql_error) + }) + .await + } + + pub async fn rename(&self, id: String, name: String) -> Result<(), String> { + bounded_text(&name, 256, "Backup name")?; + self.access(move |conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?; + let payload: String = tx + .query_row( + "SELECT payload FROM runs WHERE id=? AND state IN ('success','failed','cancelled')", + [&id], + |r| r.get(0), + ) + .map_err(sql_error)?; + let mut run: BackupRun = decode(payload)?; + run.display_name = Some(name); + tx.execute("UPDATE runs SET payload=? WHERE id=?", params![encode(&run)?, id]).map_err(sql_error)?; + tx.commit().map_err(sql_error) + }) + .await + } + + pub(crate) async fn mark_deleting(&self, ids: Vec) -> Result, String> { + self.access(move |conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?; + let mut runs = Vec::new(); + for id in ids { + let payload: String = tx + .query_row( + "SELECT payload FROM runs WHERE id=? AND state IN ('success','failed','cancelled','deleting')", + [&id], + |r| r.get(0), + ) + .map_err(sql_error)?; + runs.push(decode(payload)?); + tx.execute("UPDATE runs SET state='deleting' WHERE id=?", [id]).map_err(sql_error)?; + } + tx.commit().map_err(sql_error)?; + Ok(runs) + }) + .await + } + + pub(crate) async fn remove_run(&self, id: String) -> Result<(), String> { + self.access(move |conn| { + conn.execute("DELETE FROM runs WHERE id=? AND state='deleting'", [id]).map_err(sql_error)?; + Ok(()) + }) + .await + } + + pub(crate) async fn deletion_failed(&self, run: BackupRun) -> Result<(), String> { + self.access(move |conn| { + conn.execute( + "UPDATE runs SET state=?,payload=? WHERE id=? AND state='deleting'", + params![run.status, encode(&run)?, run.id], + ) + .map_err(sql_error)?; + Ok(()) + }) + .await + } + + pub(crate) async fn heartbeat(&self) -> Result<(), String> { + self.access(|conn| { conn.execute("INSERT INTO metadata(key,value) VALUES('heartbeat',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", [Utc::now().to_rfc3339()]).map_err(sql_error)?; Ok(()) }).await + } + + pub(crate) async fn recover(&self) -> Result<(), String> { + self.access(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate).map_err(sql_error)?; + for mut run in run_rows(&tx)? { + let state: String = + tx.query_row("SELECT state FROM runs WHERE id=?", [&run.id], |r| r.get(0)).map_err(sql_error)?; + if state != "running" && state != "deleting" { + continue; + } + run.status = "failed".into(); + run.error = + Some("Backup interrupted: the previous worker stopped. Retained files may be incomplete.".into()); + run.completed_at = Some(Utc::now().to_rfc3339()); + tx.execute( + "UPDATE runs SET state='failed',payload=?,job=NULL WHERE id=?", + params![encode(&run)?, run.id], + ) + .map_err(sql_error)?; + } + tx.commit().map_err(sql_error) + }) + .await + } +} diff --git a/crates/dbx-core/src/scheduled_backup/tests.rs b/crates/dbx-core/src/scheduled_backup/tests.rs new file mode 100644 index 0000000000..e8edf7f5ae --- /dev/null +++ b/crates/dbx-core/src/scheduled_backup/tests.rs @@ -0,0 +1,642 @@ +use super::*; +use chrono::{DateTime, Utc}; +use serde_json::json; +use std::{sync::Arc, time::Duration}; +use tokio_util::sync::CancellationToken; + +fn schedule(dir: &std::path::Path) -> BackupSchedule { + serde_json::from_value(json!({ + "id":"daily", "name":"Daily backup", "enabled":true, "connectionId":"mysql", "databases":[], + "destinationDirectory":dir.to_string_lossy(), "includeStructure":true, "includeData":true, "includeObjects":true, + "frequency":"daily", "intervalHours":1, "timeOfDay":"02:30", "weekday":0, "retentionCount":2, + "timeZone":"America/New_York", "createdAt":"2026-01-01T00:00:00Z", "updatedAt":"2026-01-01T00:00:00Z", + "nextRunAt":"2026-01-01T00:00:00Z" + })).unwrap() +} +fn utc(value: &str) -> DateTime { + value.parse().unwrap() +} +fn request(config: BackupConfig) -> RunRequest { + RunRequest { schedule_id: None, config: Some(config), display_name: None, time_zone: None } +} + +#[test] +fn wall_clock_schedule_handles_dst_and_does_not_repeat_ambiguous_time() { + let dir = tempfile::tempdir().unwrap(); + let mut schedule = schedule(dir.path()); + assert_eq!(schedule.next_after(utc("2026-03-08T06:00:00Z")).unwrap(), utc("2026-03-08T07:00:00Z")); + schedule.time_of_day = "01:30".into(); + assert_eq!(schedule.next_after(utc("2026-11-01T04:00:00Z")).unwrap(), utc("2026-11-01T05:30:00Z")); + assert_eq!(schedule.next_after(utc("2026-11-01T05:31:00Z")).unwrap(), utc("2026-11-02T06:30:00Z")); +} + +#[test] +fn hourly_uses_elapsed_time_and_weekly_uses_saved_zone() { + let dir = tempfile::tempdir().unwrap(); + let mut schedule = schedule(dir.path()); + schedule.frequency = "hourly".into(); + assert_eq!(schedule.next_after(utc("2026-03-08T06:30:00Z")).unwrap(), utc("2026-03-08T07:30:00Z")); + schedule.frequency = "weekly".into(); + schedule.time_zone = "Asia/Shanghai".into(); + assert_eq!(schedule.next_after(utc("2026-09-12T10:00:00Z")).unwrap(), utc("2026-09-12T18:30:00Z")); +} + +#[test] +fn invalid_schedule_and_path_templates_are_rejected() { + let dir = tempfile::tempdir().unwrap(); + let mut schedule = schedule(dir.path()); + schedule.time_zone = "invalid/timezone".into(); + assert!(schedule.validate().is_err()); + for path in ["../escape", "/absolute", "C:\\escape", "safe/../escape", "a/", "a\\..\\b"] { + assert!(super::models::validate_template(path, true).is_err(), "{path}"); + } + assert!(super::models::validate_template("{date}/{schedule}/{runId}", true).is_ok()); + assert!(super::models::matches_pattern("app.public.user?", "users", "app", "public", true)); + assert!(!super::models::matches_pattern("PUBLIC.*", "users", "app", "public", true)); + assert!(super::models::matches_pattern("PUBLIC.*", "users", "app", "public", false)); +} + +#[tokio::test] +async fn schedule_survives_reopen_and_rejects_stale_edit() { + let dir = tempfile::tempdir().unwrap(); + let store = BackupStore::new(dir.path()); + let first = store.save_schedule(schedule(dir.path())).await.unwrap(); + let mut next = first.clone(); + next.name = "Renamed".into(); + store.save_schedule(next).await.unwrap(); + assert!(store.save_schedule(first).await.unwrap_err().contains("changed")); + let reopened = BackupStore::new(dir.path()).snapshot().await.unwrap(); + assert_eq!(reopened.schedules[0].name, "Renamed"); + assert!(DateTime::parse_from_rfc3339(&reopened.schedules[0].next_run_at).unwrap().with_timezone(&Utc) > Utc::now()); +} + +#[tokio::test] +async fn migration_is_atomic_idempotent_and_keeps_only_one_catchup_job() { + let dir = tempfile::tempdir().unwrap(); + let store = BackupStore::new(dir.path()); + let mut invalid = schedule(dir.path()); + invalid.time_zone = "bad".into(); + assert!(store.migrate(Migration { schedules: vec![schedule(dir.path()), invalid], runs: vec![] }).await.is_err()); + assert!(!store.snapshot().await.unwrap().migrated); + store.migrate(Migration { schedules: vec![schedule(dir.path())], runs: vec![] }).await.unwrap(); + store.migrate(Migration::default()).await.unwrap(); + let (a, b) = tokio::join!(store.enqueue_due(), store.enqueue_due()); + a.unwrap(); + b.unwrap(); + let snapshot = store.snapshot().await.unwrap(); + assert!(snapshot.migrated); + assert_eq!(snapshot.schedules.len(), 1); + assert_eq!(snapshot.runs.len(), 1); + assert_eq!(snapshot.runs[0].trigger, "scheduled"); + assert!(utc(&snapshot.schedules[0].next_run_at) > Utc::now()); + assert!(store.delete_schedule("daily".into()).await.is_err()); +} + +#[tokio::test] +async fn concurrent_manual_requests_do_not_duplicate_a_schedule() { + let dir = tempfile::tempdir().unwrap(); + let store = BackupStore::new(dir.path()); + store.save_schedule(schedule(dir.path())).await.unwrap(); + let req = RunRequest { schedule_id: Some("daily".into()), config: None, display_name: None, time_zone: None }; + let (left, right) = tokio::join!(store.enqueue(req.clone()), store.enqueue(req)); + assert_ne!(left.is_ok(), right.is_ok()); + assert_eq!(store.snapshot().await.unwrap().runs.len(), 1); +} + +#[tokio::test] +async fn recovery_preserves_queued_jobs_and_marks_orphaned_running_jobs_failed() { + let dir = tempfile::tempdir().unwrap(); + let store = BackupStore::new(dir.path()); + let run = store.enqueue(request(schedule(dir.path()).config)).await.unwrap(); + store.recover().await.unwrap(); + assert_eq!(store.claim().await.unwrap().unwrap().run.id, run.id); + store.recover().await.unwrap(); + assert!(store.claim().await.unwrap().is_none()); + let recovered = &store.snapshot().await.unwrap().runs[0]; + assert_eq!(recovered.status, "failed"); + assert!(recovered.error.as_ref().unwrap().contains("interrupted")); +} + +async fn service(dir: &std::path::Path, root: Option) -> BackupService { + let storage = crate::storage::Storage::open(&dir.join("dbx.db")).await.unwrap(); + BackupService::new(Arc::new(crate::connection::AppState::new(storage)), dir, root) +} + +#[tokio::test] +async fn web_rejects_external_directories_and_untrusted_history_import() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("backups"); + std::fs::create_dir(&root).unwrap(); + let service = service(dir.path(), Some(root.clone())).await; + assert!(service.command(BackupCommand::Save { schedule: schedule(dir.path()) }).await.is_err()); + assert!(service + .command(BackupCommand::Migrate { migration: Migration { schedules: vec![schedule(&root)], runs: vec![] } }) + .await + .is_err()); + service.command(BackupCommand::Save { schedule: schedule(&root) }).await.unwrap(); + let snapshot = service.store.snapshot().await.unwrap(); + assert_eq!(snapshot.schedules.len(), 1); +} + +#[tokio::test] +async fn deletion_only_removes_recorded_files_within_the_backup_root() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("backups"); + std::fs::create_dir(&root).unwrap(); + let service = service(dir.path(), Some(root.clone())).await; + let inside = root.join("test.sql"); + std::fs::write(&inside, "SELECT 1;").unwrap(); + let outside = dir.path().join("outside.sql"); + std::fs::write(&outside, "keep").unwrap(); + let mut run = service.store.enqueue(request(schedule(&root).config)).await.unwrap(); + service.store.claim().await.unwrap(); + run.status = "success".into(); + run.files = vec![BackupFile { + database: "app".into(), + schema: "app".into(), + display_name: "test".into(), + file_path: outside.to_string_lossy().into_owned(), + owned: true, + }]; + service.store.finish(run.clone()).await.unwrap(); + assert!(service.file(&run.id, 0).await.is_err()); + assert!(service.delete_runs(vec![run.id.clone()]).await.is_err()); + assert!(outside.exists()); + assert!(inside.exists()); + run.files[0].file_path = inside.to_string_lossy().into_owned(); + run.files[0].owned = false; + service.store.finish(run.clone()).await.unwrap(); + assert!(service.delete_runs(vec![run.id.clone()]).await.is_err()); + assert!(inside.exists()); + run.files[0].owned = true; + service.store.finish(run.clone()).await.unwrap(); + assert_eq!(service.file(&run.id, 0).await.unwrap(), inside.canonicalize().unwrap()); + assert!(service.file(&run.id, 1).await.is_err()); + assert!(service.file("unknown", 0).await.is_err()); + service.delete_runs(vec![run.id]).await.unwrap(); + assert!(!inside.exists()); + assert!(outside.exists()); + assert!(service.store.snapshot().await.unwrap().runs.is_empty()); +} + +#[tokio::test] +async fn draining_transfers_leadership_without_cancelling_and_cancel_survives_reopen() { + let dir = tempfile::tempdir().unwrap(); + let service = service(dir.path(), None).await; + let run = service.store.enqueue(request(schedule(dir.path()).config)).await.unwrap(); + assert!(service.store.cancel(run.id.clone()).await.unwrap()); + let first_stop = CancellationToken::new(); + let first_drain = CancellationToken::new(); + let second_stop = CancellationToken::new(); + let first = service.start_with_drain(first_stop.clone(), first_drain.clone()); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let snapshot = service.store.snapshot().await.unwrap(); + if snapshot.heartbeat.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .unwrap(); + let probe = + std::fs::OpenOptions::new().read(true).write(true).open(service.store.directory.join("worker.lock")).unwrap(); + assert!(fs2::FileExt::try_lock_exclusive(&probe).is_err()); + let second = service.start(second_stop.clone()); + tokio::time::sleep(Duration::from_millis(100)).await; + first_drain.cancel(); + tokio::time::timeout(Duration::from_secs(5), first).await.unwrap().unwrap(); + assert!(!first_stop.is_cancelled()); + let previous = service.store.snapshot().await.unwrap().heartbeat; + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if service.store.snapshot().await.unwrap().heartbeat != previous { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .unwrap(); + assert!(fs2::FileExt::try_lock_exclusive(&probe).is_err()); + second_stop.cancel(); + second.await.unwrap(); + fs2::FileExt::try_lock_exclusive(&probe).unwrap(); + fs2::FileExt::unlock(&probe).unwrap(); + let snapshot = BackupStore::new(dir.path()).snapshot().await.unwrap(); + assert_eq!(snapshot.runs.len(), 1); + assert_eq!(snapshot.runs[0].status, "cancelled"); +} + +#[tokio::test] +async fn worker_waits_for_security_migration_and_resumes_without_restarting() { + let directory = tempfile::tempdir().unwrap(); + let legacy_path = directory.path().join("connections.json"); + std::fs::write(&legacy_path, "[]").unwrap(); + let storage = crate::storage::Storage::open_unmigrated(&directory.path().join("dbx.db")).await.unwrap(); + let service = BackupService::new(Arc::new(crate::connection::AppState::new(storage)), directory.path(), None); + let queued = service.store.enqueue(request(schedule(directory.path()).config)).await.unwrap(); + assert!(!service.state.storage.inspect_data_migration().await.unwrap().is_ready()); + let stop = CancellationToken::new(); + let worker = service.start(stop.clone()); + + tokio::time::sleep(Duration::from_millis(2200)).await; + let waiting = service.store.snapshot().await.unwrap(); + assert!(waiting.heartbeat.is_none()); + assert_eq!(waiting.runs[0].id, queued.id); + assert_eq!(waiting.runs[0].status, queued.status); + assert_eq!(std::fs::read_to_string(&legacy_path).unwrap(), "[]"); + assert!(!directory.path().join("connections.json.bak").exists()); + + service.state.storage.start_data_migration().await.unwrap(); + let resumed = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let snapshot = service.store.snapshot().await.unwrap(); + if snapshot.heartbeat.is_some() && snapshot.runs[0].status == "failed" { + break snapshot; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await; + stop.cancel(); + tokio::time::timeout(Duration::from_secs(5), worker).await.unwrap().unwrap(); + let resumed = resumed.unwrap(); + assert_eq!(resumed.runs[0].id, queued.id); + assert!(resumed.runs[0].error.is_some()); + assert!(!legacy_path.exists()); + assert!(directory.path().join("connections.json.bak").exists()); +} + +#[tokio::test] +async fn stopped_or_drained_startup_does_not_consume_existing_jobs() { + let dir = tempfile::tempdir().unwrap(); + let service = service(dir.path(), None).await; + let queued = service.store.enqueue(request(schedule(dir.path()).config)).await.unwrap(); + for draining in [false, true] { + let stop = CancellationToken::new(); + let drain = CancellationToken::new(); + if draining { + drain.cancel(); + } else { + stop.cancel(); + } + tokio::time::timeout(Duration::from_secs(5), service.start_with_drain(stop, drain)).await.unwrap().unwrap(); + let snapshot = service.store.snapshot().await.unwrap(); + assert!(snapshot.heartbeat.is_none()); + assert_eq!(snapshot.runs.len(), 1); + assert_eq!(snapshot.runs[0].id, queued.id); + assert_eq!(snapshot.runs[0].status, queued.status); + } + assert_eq!(service.store.claim().await.unwrap().unwrap().run.id, queued.id); +} + +#[tokio::test] +async fn upgrade_reopen_recovers_interrupted_work_without_losing_queued_jobs_or_disabled_schedules() { + let dir = tempfile::tempdir().unwrap(); + let store = BackupStore::new(dir.path()); + let mut disabled = schedule(dir.path()); + disabled.enabled = false; + store.save_schedule(disabled).await.unwrap(); + let interrupted = store.enqueue(request(schedule(dir.path()).config)).await.unwrap(); + assert_eq!(store.claim().await.unwrap().unwrap().run.id, interrupted.id); + let queued = store.enqueue(request(schedule(dir.path()).config)).await.unwrap(); + let cancelled = store.enqueue(request(schedule(dir.path()).config)).await.unwrap(); + store.cancel(cancelled.id.clone()).await.unwrap(); + drop(store); + + let reopened = BackupStore::new(dir.path()); + reopened.recover().await.unwrap(); + reopened.recover().await.unwrap(); + let snapshot = reopened.snapshot().await.unwrap(); + assert!(!snapshot.schedules[0].enabled); + assert_eq!(snapshot.runs.len(), 3); + assert_eq!(snapshot.runs.iter().find(|run| run.id == interrupted.id).unwrap().status, "failed"); + assert_eq!(snapshot.runs.iter().find(|run| run.id == cancelled.id).unwrap().status, "cancelled"); + assert_eq!(reopened.claim().await.unwrap().unwrap().run.id, queued.id); + assert!(reopened.claim().await.unwrap().is_none()); +} + +#[test] +#[ignore = "requires a disposable MySQL endpoint configured by DBX_LIVE_SQL_FILE_MYSQL_* variables"] +fn live_mysql_worker_exports_saved_connection_and_applies_retention() { + live_backup_runtime().block_on(live_mysql_worker_scenario()); +} + +async fn live_mysql_worker_scenario() { + use crate::{models::connection::ConnectionConfig, query::execute_sql_statement}; + use futures::FutureExt; + use std::io::Read; + let dir = tempfile::tempdir().unwrap(); + let service = service(dir.path(), None).await; + let config: ConnectionConfig = serde_json::from_value(json!({ + "id":"mysql", "name":"Worker test", "db_type":"mysql", "save_password":true, + "host":std::env::var("DBX_LIVE_SQL_FILE_MYSQL_HOST").unwrap(), + "port":std::env::var("DBX_LIVE_SQL_FILE_MYSQL_PORT").ok().and_then(|p| p.parse::().ok()).unwrap_or(3306), + "username":std::env::var("DBX_LIVE_SQL_FILE_MYSQL_USER").unwrap(), + "password":std::env::var("DBX_LIVE_SQL_FILE_MYSQL_PASSWORD").unwrap(), + "database":null, "connect_timeout_secs":10, "query_timeout_secs":30 + })) + .unwrap(); + service.state.storage.save_connections(std::slice::from_ref(&config)).await.unwrap(); + service.state.configs.write().await.insert(config.id.clone(), config); + let database = format!("dbx_worker_{}", uuid::Uuid::new_v4().simple()); + execute_sql_statement(&service.state, "mysql", "", &format!("CREATE DATABASE `{database}`"), None, None) + .await + .unwrap(); + let stop = CancellationToken::new(); + let worker = service.start(stop.clone()); + let outcome = std::panic::AssertUnwindSafe(async { + execute_sql_statement( + &service.state, + "mysql", + &database, + "CREATE TABLE chosen (id INT PRIMARY KEY, value TEXT)", + None, + None, + ) + .await?; + execute_sql_statement( + &service.state, + "mysql", + &database, + "INSERT INTO chosen VALUES (101, 'background-worker-sentinel')", + None, + None, + ) + .await?; + execute_sql_statement(&service.state, "mysql", &database, "CREATE TABLE skipped (id INT)", None, None).await?; + let mut plan = schedule(dir.path()); + plan.enabled = false; + plan.retention_count = 1; + plan.config.databases = vec![database.clone()]; + plan.config.output_compression = "gzip".into(); + plan.config.table_filter_mode = "include".into(); + plan.config.table_patterns = vec!["chosen".into()]; + service.command(BackupCommand::Save { schedule: plan }).await?; + let mut previous = None; + for _ in 0..2 { + let run = service + .store + .enqueue(RunRequest { + schedule_id: Some("daily".into()), + config: None, + display_name: None, + time_zone: None, + }) + .await?; + let completed = tokio::time::timeout(Duration::from_secs(60), async { + loop { + let snapshot = service.store.snapshot().await.unwrap(); + let current = snapshot.runs.iter().find(|r| r.id == run.id).unwrap(); + if current.status != "running" { + break current.clone(); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .map_err(|_| "Backup timed out")?; + assert_eq!(completed.status, "success", "{:?}", completed.error); + assert_eq!(completed.files.len(), 1); + let path = std::path::PathBuf::from(&completed.files[0].file_path); + let mut sql = String::new(); + flate2::read::GzDecoder::new(std::fs::File::open(&path).unwrap()).read_to_string(&mut sql).unwrap(); + assert!(sql.contains("background-worker-sentinel")); + assert!(!sql.contains("CREATE TABLE `skipped`")); + if let Some(previous) = previous { + tokio::time::timeout(Duration::from_secs(5), async { + while std::path::Path::new(&previous).exists() { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .map_err(|_| "Retention did not remove the old file")?; + } + previous = Some(path); + } + assert_eq!(service.store.snapshot().await?.runs.len(), 1); + let mut empty_scope = schedule(dir.path()).config; + empty_scope.databases = vec![database.clone()]; + empty_scope.table_filter_mode = "include".into(); + empty_scope.table_patterns = vec!["missing_table".into()]; + let run = service.store.enqueue(request(empty_scope)).await?; + let completed = tokio::time::timeout(Duration::from_secs(60), async { + loop { + let snapshot = service.store.snapshot().await.unwrap(); + let current = snapshot.runs.iter().find(|r| r.id == run.id).unwrap(); + if current.status != "running" { + break current.clone(); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .map_err(|_| "Empty-scope backup timed out")?; + assert_eq!(completed.status, "failed"); + assert!(completed.error.as_deref().unwrap().contains("No tables matched")); + assert!(completed.files.is_empty()); + assert!(previous.unwrap().exists()); + Ok::<_, String>(()) + }) + .catch_unwind() + .await; + stop.cancel(); + worker.await.unwrap(); + let cleanup = + execute_sql_statement(&service.state, "mysql", "", &format!("DROP DATABASE `{database}`"), None, None).await; + service.state.shutdown(Duration::from_secs(3)).await; + cleanup.unwrap(); + outcome.unwrap().unwrap(); +} + +/// Runs a live scenario on a runtime configured like the processes that drive +/// backups. The export path nests very large async futures, so it needs the same +/// roomy worker stack the desktop runtime sets; on tokio's default stack the +/// process aborts with `fatal runtime error: stack overflow` instead of +/// reporting a failed assertion. +fn live_backup_runtime() -> tokio::runtime::Runtime { + super::worker_runtime().unwrap() +} + +fn live_postgres_config(id: &str, database: &str) -> crate::models::connection::ConnectionConfig { + serde_json::from_value(json!({ + "id": id, "name": id, "db_type": "postgres", "save_password": true, + "host": std::env::var("DBX_LIVE_SQL_FILE_POSTGRES_HOST").unwrap(), + "port": std::env::var("DBX_LIVE_SQL_FILE_POSTGRES_PORT").ok().and_then(|p| p.parse::().ok()).unwrap_or(5432), + "username": std::env::var("DBX_LIVE_SQL_FILE_POSTGRES_USER").unwrap(), + "password": std::env::var("DBX_LIVE_SQL_FILE_POSTGRES_PASSWORD").unwrap(), + "database": database, "connect_timeout_secs": 10, "query_timeout_secs": 30 + })) + .unwrap() +} + +async fn await_run(service: &BackupService, run_id: &str) -> BackupRun { + tokio::time::timeout(Duration::from_secs(60), async { + loop { + let snapshot = service.store.snapshot().await.unwrap(); + let current = snapshot.runs.iter().find(|r| r.id == run_id).unwrap().clone(); + if current.status != "running" && current.status != "queued" { + break current; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .expect("backup run did not finish within 60s") +} + +async fn enqueue_and_run(service: &BackupService, schedule_id: &str) -> BackupRun { + let run = service + .store + .enqueue(RunRequest { + schedule_id: Some(schedule_id.into()), + config: None, + display_name: None, + time_zone: None, + }) + .await + .unwrap(); + await_run(service, &run.id).await +} + +fn plan_with_id(dir: &std::path::Path, id: &str) -> BackupSchedule { + let mut plan = schedule(dir); + plan.id = id.to_string(); + plan.enabled = false; + plan +} + +fn read_backup(path: &str, gzip: bool) -> String { + let file = std::fs::File::open(path).unwrap(); + if !gzip { + return std::fs::read_to_string(path).unwrap(); + } + use std::io::Read; + let mut sql = String::new(); + flate2::read::GzDecoder::new(file).read_to_string(&mut sql).unwrap(); + sql +} + +#[test] +#[ignore = "requires a disposable PostgreSQL endpoint configured by DBX_LIVE_SQL_FILE_POSTGRES_* variables"] +fn live_postgres_worker_exports_selected_tables_across_schemas() { + use crate::query::execute_sql_statement; + live_backup_runtime().block_on(async { + let dir = tempfile::tempdir().unwrap(); + let service = service(dir.path(), None).await; + let stop = CancellationToken::new(); + let worker = service.start(stop.clone()); + let admin_id = "postgres-admin"; + let admin = live_postgres_config(admin_id, "postgres"); + service.state.storage.save_connections(std::slice::from_ref(&admin)).await.unwrap(); + service.state.configs.write().await.insert(admin.id.clone(), admin); + let database = format!("dbx_pg_backup_{}", uuid::Uuid::new_v4().simple()); + execute_sql_statement( + &service.state, + admin_id, + "postgres", + &format!("CREATE DATABASE \"{database}\""), + None, + None, + ) + .await + .unwrap(); + let config = live_postgres_config("postgres", &database); + service.state.storage.save_connections(std::slice::from_ref(&config)).await.unwrap(); + service.state.configs.write().await.insert(config.id.clone(), config); + + for statement in [ + "CREATE SCHEMA app", + "CREATE SCHEMA audit", + "CREATE TABLE public.accounts (id integer PRIMARY KEY, name text NOT NULL)", + "CREATE TABLE app.orders (id integer PRIMARY KEY, total numeric(12,2) NOT NULL)", + "CREATE TABLE app.\"MixedCase\" (id integer PRIMARY KEY)", + "CREATE VIEW app.big_orders AS SELECT id, total FROM app.orders WHERE total > 100", + "CREATE TABLE audit.events (id integer PRIMARY KEY, note text)", + "INSERT INTO public.accounts VALUES (1, 'Alice')", + "INSERT INTO app.orders VALUES (7, 42.50), (8, 420.00)", + "INSERT INTO app.\"MixedCase\" VALUES (1)", + "INSERT INTO audit.events VALUES (9, 'sentinel-event')", + ] { + execute_sql_statement(&service.state, "postgres", &database, statement, None, None).await.unwrap(); + } + + // Table scope: only `app.orders` (schema-qualified pattern) and the + // case-sensitive `MixedCase` name, which pg resolves case-sensitively. + let mut plan = plan_with_id(dir.path(), "scope"); + plan.retention_count = 2; + plan.config.connection_id = "postgres".into(); + plan.config.databases = vec![database.clone()]; + plan.config.output_compression = "gzip".into(); + plan.config.table_filter_mode = "include".into(); + plan.config.table_patterns = vec!["app.orders".into(), "MixedCase".into()]; + service.command(BackupCommand::Save { schedule: plan }).await.unwrap(); + + let completed = enqueue_and_run(&service, "scope").await; + assert_eq!(completed.status, "success", "{:?}", completed.error); + assert_eq!( + completed.files.len(), + 1, + "{:?}", + completed.files.iter().map(|f| &f.display_name).collect::>() + ); + let sql = read_backup(&completed.files[0].file_path, true); + assert!(sql.contains("CREATE TABLE \"app\".\"orders\""), "{sql}"); + assert!(sql.contains("\"MixedCase\""), "{sql}"); + assert!(sql.contains("420.00"), "{sql}"); + assert!(!sql.contains("accounts"), "{sql}"); + assert!(!sql.contains("events"), "{sql}"); + // A view inside the same schema is not part of a table-name scope. + assert!(!sql.contains("big_orders"), "{sql}"); + + // Patterns are case-sensitive on PostgreSQL, and a scope that matches + // nothing must fail without leaving a partial file behind. + let mut empty = plan_with_id(dir.path(), "empty"); + empty.config.connection_id = "postgres".into(); + empty.config.databases = vec![database.clone()]; + empty.config.output_compression = "gzip".into(); + empty.config.table_filter_mode = "include".into(); + empty.config.table_patterns = vec!["mixedcase".into()]; + service.command(BackupCommand::Save { schedule: empty }).await.unwrap(); + let cancelled = enqueue_and_run(&service, "empty").await; + assert_eq!(cancelled.status, "failed"); + assert!(cancelled.error.as_deref().unwrap().contains("No tables matched"), "{:?}", cancelled.error); + assert!(cancelled.files.is_empty()); + + // Every schema of the database is exported when no table scope is set. + let mut full = plan_with_id(dir.path(), "full"); + full.config.connection_id = "postgres".into(); + full.config.databases = vec![database.clone()]; + full.config.output_compression = "gzip".into(); + service.command(BackupCommand::Save { schedule: full }).await.unwrap(); + let full_run = enqueue_and_run(&service, "full").await; + assert_eq!(full_run.status, "success", "{:?}", full_run.error); + assert_eq!(full_run.files.len(), 3, "{:?}", full_run.files.iter().map(|f| &f.display_name).collect::>()); + let bodies = + full_run.files.iter().map(|f| (f.schema.clone(), read_backup(&f.file_path, true))).collect::>(); + let app = bodies.iter().find(|(schema, _)| schema == "app").unwrap(); + assert!(app.1.contains("CREATE TABLE \"app\".\"MixedCase\"")); + assert!(app.1.contains("big_orders"), "views belong to the schema-wide export"); + let audit = bodies.iter().find(|(schema, _)| schema == "audit").unwrap(); + assert!(audit.1.contains("sentinel-event")); + assert!(!audit.1.contains("orders")); + let public = bodies.iter().find(|(schema, _)| schema == "public").unwrap(); + assert!(public.1.contains("accounts")); + + stop.cancel(); + worker.await.unwrap(); + let cleanup = execute_sql_statement( + &service.state, + admin_id, + "postgres", + &format!("DROP DATABASE \"{database}\" WITH (FORCE)"), + None, + None, + ) + .await; + service.state.shutdown(Duration::from_secs(3)).await; + cleanup.unwrap(); + }); +} diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 408a142530..6d41ec0ba5 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -312,8 +312,12 @@ fn add_mq_routes(router: Router>) -> Router> { router } -#[tokio::main] -async fn main() { +fn main() { + let runtime = dbx_core::scheduled_backup::worker_runtime().expect("Failed to build tokio runtime"); + runtime.block_on(serve()); +} + +async fn serve() { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() @@ -394,6 +398,7 @@ async fn main() { transfer_progress_channels: RwLock::new(HashMap::new()), table_import_channels: RwLock::new(HashMap::new()), sql_file_executions: RwLock::new(HashMap::new()), + managed_sql_previews: Default::default(), nacos_imports: RwLock::new(HashMap::new()), login_rate_limit: tokio::sync::Mutex::new(state::LoginRateLimit { fail_count: 0, locked_until: None }), export_files: RwLock::new(HashMap::new()), @@ -402,6 +407,15 @@ async fn main() { }); ssh_prompt::install_web_ssh_prompt_bridge(web_state.ssh_prompts.clone()); + routes::sql_file::start_sql_file_cleanup(&web_state); + + let backup_root = std::env::var_os("DBX_BACKUP_ROOT") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| web_state.data_dir.join("backups")); + std::fs::create_dir_all(&backup_root).expect("Failed to create server backup root"); + let backup_service = routes::scheduled_backup::service(&web_state).expect("Failed to resolve server backup root"); + let backup_stop = tokio_util::sync::CancellationToken::new(); + let backup_worker = backup_service.start(backup_stop.clone()); // API routes let api = Router::new() @@ -409,6 +423,9 @@ async fn main() { .route("/migration/start", post(routes::migration::start)) .route("/migration/retry", post(routes::migration::retry)) .route("/migration/cleanup-backups", post(routes::migration::cleanup_backups)) + .route("/database-backups", post(routes::scheduled_backup::command)) + .route("/database-backups/{id}/files/{index}", get(routes::scheduled_backup::download)) + .route("/database-backups/{id}/files/{index}/restore", post(routes::scheduled_backup::prepare_restore)) // Auth .route("/auth/login", post(auth::login)) .route("/auth/check", get(auth::check)) @@ -1137,6 +1154,7 @@ async fn main() { .layer(DefaultBodyLimit::max(routes::sql_file::sql_file_upload_hard_cap_bytes())), ) .route("/sql-file/execute", post(routes::sql_file::execute_sql_file)) + .route("/sql-file/preview/release", post(routes::sql_file::release_sql_file_preview)) .route("/sql-file/tables", post(routes::sql_file::inspect_sql_file_tables)) .route("/sql-file/progress/{executionId}", get(routes::sql_file::sql_file_progress)) .route("/sql-file/cancel", post(routes::sql_file::cancel_sql_file)) @@ -1263,13 +1281,20 @@ async fn main() { let listener = tokio::net::TcpListener::bind(addr).await.expect("Failed to bind address"); let shutdown_state = web_state.app.clone(); axum::serve(listener, app) - .with_graceful_shutdown(async { - if let Err(error) = tokio::signal::ctrl_c().await { - tracing::warn!("Failed to listen for shutdown signal: {error}"); + .with_graceful_shutdown(async move { + #[cfg(unix)] + { + let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("Failed to listen for SIGTERM"); + tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = terminate.recv() => {} } } + #[cfg(not(unix))] + let _ = tokio::signal::ctrl_c().await; + backup_stop.cancel(); }) .await .expect("Server error"); + let _ = tokio::time::timeout(std::time::Duration::from_secs(60), backup_worker).await; shutdown_state.shutdown(std::time::Duration::from_secs(3)).await; } diff --git a/crates/dbx-web/src/routes/mod.rs b/crates/dbx-web/src/routes/mod.rs index 44ce656281..cad1245de3 100644 --- a/crates/dbx-web/src/routes/mod.rs +++ b/crates/dbx-web/src/routes/mod.rs @@ -30,6 +30,7 @@ pub mod query_result_export; pub mod redis; pub mod redis_pubsub_ws; pub mod saved_sql; +pub mod scheduled_backup; pub mod schema; pub mod schema_cache; pub mod schema_diff; diff --git a/crates/dbx-web/src/routes/mq.rs b/crates/dbx-web/src/routes/mq.rs index f99a07a7d0..cc5a60e47e 100644 --- a/crates/dbx-web/src/routes/mq.rs +++ b/crates/dbx-web/src/routes/mq.rs @@ -1567,6 +1567,7 @@ mod tests { transfer_progress_channels: RwLock::new(HashMap::new()), table_import_channels: RwLock::new(HashMap::new()), sql_file_executions: RwLock::new(HashMap::new()), + managed_sql_previews: Default::default(), nacos_imports: RwLock::new(HashMap::new()), login_rate_limit: Mutex::new(LoginRateLimit { fail_count: 0, locked_until: None }), export_files: RwLock::new(HashMap::new()), diff --git a/crates/dbx-web/src/routes/scheduled_backup.rs b/crates/dbx-web/src/routes/scheduled_backup.rs new file mode 100644 index 0000000000..abdf28c02c --- /dev/null +++ b/crates/dbx-web/src/routes/scheduled_backup.rs @@ -0,0 +1,48 @@ +use crate::{error::AppError, state::WebState}; +use axum::{ + body::Body, + extract::{Path, State}, + http::header, + response::Response, + Json, +}; +use dbx_core::scheduled_backup::{BackupCommand, BackupService}; +use std::{path::PathBuf, sync::Arc}; +use tokio_util::io::ReaderStream; + +pub fn service(state: &WebState) -> Result { + let root = std::env::var_os("DBX_BACKUP_ROOT").map(PathBuf::from).unwrap_or_else(|| state.data_dir.join("backups")); + let root = root.canonicalize().map_err(|e| format!("Cannot resolve the server backup root: {e}"))?; + Ok(BackupService::new(state.app.clone(), &state.data_dir, Some(root))) +} + +pub async fn command( + State(state): State>, + Json(command): Json, +) -> Result, AppError> { + Ok(Json(service(&state)?.command(command).await?)) +} + +pub async fn download( + State(state): State>, + Path((id, index)): Path<(String, usize)>, +) -> Result { + let path = service(&state)?.file(&id, index).await?; + let name = path.file_name().unwrap_or_default().to_string_lossy(); + let disposition = super::export_download::attachment_content_disposition(&name); + let file = tokio::fs::File::open(&path).await.map_err(|e| AppError::from(e.to_string()))?; + Response::builder() + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::CONTENT_DISPOSITION, disposition) + .header(header::CACHE_CONTROL, "no-store") + .body(Body::from_stream(ReaderStream::new(file))) + .map_err(|e| AppError::from(e.to_string())) +} + +pub async fn prepare_restore( + State(state): State>, + Path((id, index)): Path<(String, usize)>, +) -> Result, AppError> { + let source = service(&state)?.file(&id, index).await?; + Ok(Json(super::sql_file::prepare_backup_preview(&state, source).await?)) +} diff --git a/crates/dbx-web/src/routes/sql_file.rs b/crates/dbx-web/src/routes/sql_file.rs index 9c216d6bee..af4fc796a0 100644 --- a/crates/dbx-web/src/routes/sql_file.rs +++ b/crates/dbx-web/src/routes/sql_file.rs @@ -1,6 +1,7 @@ +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; +use std::sync::{Arc, Mutex, Weak}; +use std::time::{Duration, Instant}; use axum::extract::{Multipart, Path as AxumPath, State}; use axum::response::sse::{Event, Sse}; @@ -25,6 +26,119 @@ pub const SQL_FILE_UPLOAD_MAX_BYTES: usize = 200 * 1024 * 1024; const SQL_FILE_UPLOAD_MIN_BYTES: usize = 1024 * 1024; const SQL_FILE_UPLOAD_MAX_BYTES_LIMIT: usize = 4096 * 1024 * 1024; +struct ManagedPreview { + created: Instant, + directory: Weak, + pending: Option>, +} + +#[derive(Default)] +pub struct ManagedSqlPreviews(Mutex>); + +impl ManagedSqlPreviews { + fn insert(&self, directory: tempfile::TempDir) -> String { + let directory = Arc::new(directory); + let token = directory.path().file_name().unwrap().to_string_lossy().into_owned(); + self.0.lock().unwrap().insert( + token.clone(), + ManagedPreview { created: Instant::now(), directory: Arc::downgrade(&directory), pending: Some(directory) }, + ); + token + } + + fn release(&self, token: &str) { + let mut entries = self.0.lock().unwrap(); + if entries.get(token).is_some_and(|entry| entry.pending.is_some()) { + entries.remove(token); + } + } + + fn claim(&self, paths: &[PathBuf]) -> Result>, AppError> { + let mut entries = self.0.lock().unwrap(); + let mut tokens = HashSet::new(); + for path in paths { + let Some(parent) = path.parent() else { continue }; + let Some(token) = parent.file_name().and_then(|name| name.to_str()) else { continue }; + if !token.starts_with("restore-") { + continue; + } + let valid = entries + .get(token) + .and_then(|entry| entry.pending.as_ref()) + .is_some_and(|directory| directory.path().canonicalize().is_ok_and(|directory| directory == parent)); + if !valid { + return Err(AppError::from("Backup preview expired or already consumed".to_string())); + } + tokens.insert(token.to_owned()); + } + Ok(tokens.into_iter().map(|token| entries.get_mut(&token).unwrap().pending.take().unwrap()).collect()) + } + + fn cleanup(&self, tmp_dir: &Path, max_age: Duration, managed_only: bool) { + let mut entries = self.0.lock().unwrap(); + for entry in entries.values_mut() { + if entry.created.elapsed() >= max_age { + entry.pending = None; + } + } + entries.retain(|_, entry| entry.directory.strong_count() > 0); + cleanup_sql_file_uploads_except(tmp_dir, max_age, &entries.keys().cloned().collect(), managed_only); + } +} + +pub fn start_sql_file_cleanup(state: &Arc) { + let state = Arc::downgrade(state); + tokio::spawn(async move { + loop { + let Some(state) = state.upgrade() else { break }; + state.managed_sql_previews.cleanup(&state.data_dir.join("tmp/sql_file"), SQL_FILE_UPLOAD_MAX_AGE, true); + drop(state); + tokio::time::sleep(Duration::from_secs(60)).await; + } + }); +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReleaseSqlFilePreviewRequest { + pub cleanup_token: String, +} + +pub async fn release_sql_file_preview( + State(state): State>, + Json(body): Json, +) -> Json<()> { + state.managed_sql_previews.release(&body.cleanup_token); + Json(()) +} + +pub(super) async fn prepare_backup_preview(state: &WebState, source: PathBuf) -> Result { + let tmp_dir = state.data_dir.join("tmp/sql_file"); + std::fs::create_dir_all(&tmp_dir).map_err(|error| AppError::from(error.to_string()))?; + state.managed_sql_previews.cleanup(&tmp_dir, SQL_FILE_UPLOAD_MAX_AGE, true); + let limit = sql_file_upload_limit(state).await as u64; + let (directory, file_path, size) = tokio::task::spawn_blocking(move || { + let directory = tempfile::Builder::new().prefix(&format!("restore-{}-", Uuid::new_v4())).tempdir_in(tmp_dir)?; + let file_path = + directory.path().join(source.file_name().ok_or_else(|| std::io::Error::other("Invalid backup name"))?); + if std::fs::metadata(&source)?.len() > limit { + return Err(std::io::Error::other("Backup exceeds the SQL file upload limit")); + } + let size = std::fs::copy(source, &file_path)?; + if size > limit { + return Err(std::io::Error::other("Backup exceeds the SQL file upload limit")); + } + Ok((directory, file_path, size)) + }) + .await + .map_err(|error| AppError::from(error.to_string()))? + .map_err(|error: std::io::Error| AppError::from(error.to_string()))?; + let file_name = file_path.file_name().unwrap().to_string_lossy(); + let mut preview = preview_uploaded_sql_file(&file_path, &file_name, size).await?; + preview["cleanupToken"] = state.managed_sql_previews.insert(directory).into(); + Ok(preview) +} + pub fn sql_file_upload_max_bytes_from_value(value: Option<&str>) -> usize { let mb = value .and_then(|value| value.trim().parse::().ok()) @@ -82,7 +196,7 @@ pub async fn preview_sql_file( ) -> Result, AppError> { let tmp_dir = state.data_dir.join("tmp").join("sql_file"); std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError::from(e.to_string()))?; - cleanup_expired_sql_file_uploads(&tmp_dir); + state.managed_sql_previews.cleanup(&tmp_dir, SQL_FILE_UPLOAD_MAX_AGE, false); if let Some(field) = multipart.next_field().await.map_err(|e| AppError::from(e.to_string()))? { let file_name = field.file_name().unwrap_or("upload.sql").to_string(); @@ -95,48 +209,56 @@ pub async fn preview_sql_file( let file_path = safe_uploaded_sql_path(&tmp_dir, &file_name)?; std::fs::write(&file_path, &data).map_err(|e| AppError::from(e.to_string()))?; - if file_name.to_ascii_lowercase().ends_with(".zip") { - let extraction_dir = tmp_dir.join(format!("package-{}", Uuid::new_v4())); - let package = dbx_core::sql_file_zip_package::extract_sql_file_zip_package(&file_path, &extraction_dir) - .map_err(AppError::from)?; - let paths = dbx_core::sql_file_zip_package::extracted_sql_zip_paths(&extraction_dir, &package) - .into_iter() - .map(|path| path.to_string_lossy().to_string()) - .collect::>(); - let content = - sql::decode_sql_file_bytes(&std::fs::read(&paths[0]).map_err(|e| AppError::from(e.to_string()))?) - .map_err(AppError::from)?; - let preview: String = content.chars().take(20_000).collect(); - let bootstrap_analysis = dbx_core::sql_file_import::mysql_like_sql_file_bootstrap_analysis(&content); - return Ok(Json(serde_json::json!({ - "fileName": file_name, - "filePath": file_path.to_string_lossy(), - "sizeBytes": data.len(), - "preview": preview, - "canExecuteWithoutSelectedDatabase": bootstrap_analysis.can_execute_without_selected_database, - "establishesDatabaseContext": bootstrap_analysis.establishes_database_context, - "packageFilePaths": paths, - "packagePartCount": package.part_names.len(), - }))); - } + return preview_uploaded_sql_file(&file_path, &file_name, data.len() as u64).await.map(Json); + } - let size_bytes = data.len() as u64; - let content = - dbx_core::sql_file_import::read_sql_file_preview(&file_path, 1_000_000).await.map_err(AppError::from)?; + Err(AppError::from("No file uploaded".to_string())) +} + +async fn preview_uploaded_sql_file( + file_path: &Path, + file_name: &str, + size_bytes: u64, +) -> Result { + let tmp_dir = file_path.parent().ok_or_else(|| AppError::from("Invalid SQL file path".to_string()))?; + + if file_name.to_ascii_lowercase().ends_with(".zip") { + let extraction_dir = tmp_dir.join(format!("package-{}", Uuid::new_v4())); + let package = dbx_core::sql_file_zip_package::extract_sql_file_zip_package(file_path, &extraction_dir) + .map_err(AppError::from)?; + let paths = dbx_core::sql_file_zip_package::extracted_sql_zip_paths(&extraction_dir, &package) + .into_iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>(); + let content = sql::decode_sql_file_bytes(&std::fs::read(&paths[0]).map_err(|e| AppError::from(e.to_string()))?) + .map_err(AppError::from)?; let preview: String = content.chars().take(20_000).collect(); let bootstrap_analysis = dbx_core::sql_file_import::mysql_like_sql_file_bootstrap_analysis(&content); - - return Ok(Json(serde_json::json!({ + return Ok(serde_json::json!({ "fileName": file_name, "filePath": file_path.to_string_lossy(), "sizeBytes": size_bytes, "preview": preview, "canExecuteWithoutSelectedDatabase": bootstrap_analysis.can_execute_without_selected_database, "establishesDatabaseContext": bootstrap_analysis.establishes_database_context, - }))); + "packageFilePaths": paths, + "packagePartCount": package.part_names.len(), + })); } - Err(AppError::from("No file uploaded".to_string())) + let content = + dbx_core::sql_file_import::read_sql_file_preview(file_path, 1_000_000).await.map_err(AppError::from)?; + let preview: String = content.chars().take(20_000).collect(); + let bootstrap_analysis = dbx_core::sql_file_import::mysql_like_sql_file_bootstrap_analysis(&content); + + Ok(serde_json::json!({ + "fileName": file_name, + "filePath": file_path.to_string_lossy(), + "sizeBytes": size_bytes, + "preview": preview, + "canExecuteWithoutSelectedDatabase": bootstrap_analysis.can_execute_without_selected_database, + "establishesDatabaseContext": bootstrap_analysis.establishes_database_context, + })) } pub async fn execute_sql_file( @@ -145,6 +267,13 @@ pub async fn execute_sql_file( ) -> Result, AppError> { let req = body.request; + let requested_paths = if body.file_paths.is_empty() { vec![req.file_path.clone()] } else { body.file_paths }; + let file_paths = requested_paths + .iter() + .map(|file_path| validated_uploaded_sql_path(&state.data_dir, file_path)) + .collect::, _>>()?; + let managed_previews = state.managed_sql_previews.claim(&file_paths)?; + // Fast-fail: reject early if the connection is read-only (individual statements are also checked in do_execute) if let Some(name) = dbx_core::query::connection_readonly_name(&state.app, &req.connection_id).await { return Err(AppError::from(format!( @@ -154,14 +283,9 @@ pub async fn execute_sql_file( } let execution_id = req.execution_id.clone(); - let requested_paths = if body.file_paths.is_empty() { vec![req.file_path.clone()] } else { body.file_paths }; if requested_paths.is_empty() { return Err(AppError::from("No SQL files selected".to_string())); } - let file_paths = requested_paths - .iter() - .map(|file_path| validated_uploaded_sql_path(&state.data_dir, file_path)) - .collect::, _>>()?; let token = CancellationToken::new(); { @@ -180,6 +304,7 @@ pub async fn execute_sql_file( let state_clone = state.clone(); tokio::spawn(async move { + let _managed_previews = managed_previews; let started_at = std::time::Instant::now(); let mut progress_emitter = SqlFileProgressEmitter::new(|progress| { send_sql_file_progress(&tx, progress); @@ -276,15 +401,22 @@ fn safe_uploaded_sql_path(tmp_dir: &Path, file_name: &str) -> Result, managed_only: bool) { let Ok(entries) = std::fs::read_dir(tmp_dir) else { return; }; for entry in entries.flatten() { + if managed_only && !entry.file_name().to_string_lossy().starts_with("restore-") { + continue; + } + if active.contains(&entry.file_name().to_string_lossy().into_owned()) { + continue; + } let path = entry.path(); let Ok(metadata) = entry.metadata() else { continue; @@ -364,10 +496,142 @@ pub async fn cancel_sql_file( mod tests { use std::time::Duration; - use super::{ - cleanup_sql_file_uploads_older_than, safe_uploaded_sql_path, sql_file_upload_max_bytes_from_value, - validated_uploaded_sql_path, SQL_FILE_UPLOAD_MAX_BYTES, - }; + use super::*; + + async fn restore_state(directory: &Path) -> WebState { + let storage = dbx_core::storage::Storage::open(&directory.join("dbx.db")).await.unwrap(); + WebState::for_tests(Arc::new(dbx_core::connection::AppState::new(storage)), directory.to_path_buf()) + } + + #[tokio::test] + async fn restored_preview_uses_a_managed_copy_and_release_cannot_remove_the_backup() { + let directory = tempfile::tempdir().unwrap(); + let state = restore_state(directory.path()).await; + let source = directory.path().join("backup.sql"); + std::fs::write(&source, "SELECT 1;").unwrap(); + let preview = prepare_backup_preview(&state, source.clone()).await.unwrap(); + assert_eq!(preview["fileName"], "backup.sql"); + assert_eq!(preview["preview"], "SELECT 1;"); + let copy = PathBuf::from(preview["filePath"].as_str().unwrap()); + assert!(copy.starts_with(directory.path().join("tmp/sql_file"))); + assert_ne!(copy, source); + assert!(validated_uploaded_sql_path(directory.path(), copy.to_str().unwrap()).is_ok()); + state.managed_sql_previews.release(source.to_str().unwrap()); + assert!(source.exists()); + assert!(copy.exists()); + state.managed_sql_previews.release(preview["cleanupToken"].as_str().unwrap()); + state.managed_sql_previews.release(preview["cleanupToken"].as_str().unwrap()); + assert!(!copy.exists()); + assert_eq!(std::fs::read_to_string(source).unwrap(), "SELECT 1;"); + } + + #[tokio::test] + async fn failed_backup_copy_and_gzip_preview_leave_no_managed_files() { + let directory = tempfile::tempdir().unwrap(); + let state = restore_state(directory.path()).await; + assert!(prepare_backup_preview(&state, directory.path().join("missing.sql")).await.is_err()); + let source = directory.path().join("invalid.sql.gz"); + std::fs::write(&source, "not gzip").unwrap(); + assert!(prepare_backup_preview(&state, source.clone()).await.is_err()); + let oversized = directory.path().join("oversized.sql"); + std::fs::File::create(&oversized).unwrap().set_len(sql_file_upload_limit(&state).await as u64 + 1).unwrap(); + assert!(prepare_backup_preview(&state, oversized.clone()).await.is_err()); + assert!(oversized.exists()); + assert_eq!(std::fs::read_dir(directory.path().join("tmp/sql_file")).unwrap().count(), 0); + assert!(source.exists()); + } + + #[tokio::test] + async fn consumed_preview_survives_release_and_expiry_until_execution_finishes() { + let directory = tempfile::tempdir().unwrap(); + let state = restore_state(directory.path()).await; + let source = directory.path().join("backup.sql"); + std::fs::write(&source, "SELECT 1;").unwrap(); + let preview = prepare_backup_preview(&state, source.clone()).await.unwrap(); + let copy = PathBuf::from(preview["filePath"].as_str().unwrap()); + let paths = vec![copy.canonicalize().unwrap()]; + let execution = state.managed_sql_previews.claim(&paths).unwrap(); + assert!(state.managed_sql_previews.claim(&paths).is_err()); + state.managed_sql_previews.release(preview["cleanupToken"].as_str().unwrap()); + state.managed_sql_previews.cleanup(&directory.path().join("tmp/sql_file"), Duration::ZERO, true); + assert!(copy.exists()); + drop(execution); + assert!(!copy.exists()); + assert!(source.exists()); + } + + #[tokio::test] + async fn expired_and_abandoned_previews_are_removed_without_touching_ordinary_uploads() { + let directory = tempfile::tempdir().unwrap(); + let state = restore_state(directory.path()).await; + let source = directory.path().join("backup.sql"); + std::fs::write(&source, "SELECT 1;").unwrap(); + let preview = prepare_backup_preview(&state, source.clone()).await.unwrap(); + let tmp = directory.path().join("tmp/sql_file"); + let abandoned = tmp.join("restore-abandoned"); + std::fs::create_dir(&abandoned).unwrap(); + std::fs::write(abandoned.join("backup.sql"), "SELECT 2;").unwrap(); + let upload = tmp.join("upload.sql"); + std::fs::write(&upload, "SELECT 3;").unwrap(); + state.managed_sql_previews.cleanup(&tmp, Duration::ZERO, true); + assert!(!Path::new(preview["filePath"].as_str().unwrap()).exists()); + assert!(!abandoned.exists()); + assert!(upload.exists()); + assert!(source.exists()); + } + + #[tokio::test] + async fn execution_failure_releases_the_managed_copy() { + let directory = tempfile::tempdir().unwrap(); + let state = Arc::new(restore_state(directory.path()).await); + let source = directory.path().join("backup.sql"); + std::fs::write(&source, "SELECT 1;").unwrap(); + let preview = prepare_backup_preview(&state, source.clone()).await.unwrap(); + let copy = PathBuf::from(preview["filePath"].as_str().unwrap()); + let request = serde_json::from_value(serde_json::json!({ + "executionId": "restore-error", "connectionId": "missing", "database": "app", + "filePath": copy, "continueOnError": false + })) + .unwrap(); + let response = + execute_sql_file(State(state.clone()), Json(SqlFileExecuteWrapper { request, file_paths: vec![] })) + .await + .unwrap(); + assert_eq!(response.0["executionId"], "restore-error"); + tokio::time::timeout(Duration::from_secs(5), async { + while copy.exists() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert!(source.exists()); + assert!(!state.sql_file_executions.read().await.contains_key("restore-error")); + } + + #[tokio::test] + async fn duplicate_execution_releases_the_new_preview_without_cancelling_the_existing_job() { + let directory = tempfile::tempdir().unwrap(); + let state = Arc::new(restore_state(directory.path()).await); + let source = directory.path().join("backup.sql"); + std::fs::write(&source, "SELECT 1;").unwrap(); + let preview = prepare_backup_preview(&state, source.clone()).await.unwrap(); + let copy = PathBuf::from(preview["filePath"].as_str().unwrap()); + let token = CancellationToken::new(); + state.sql_file_executions.write().await.insert("existing-job".into(), token.clone()); + let request = serde_json::from_value(serde_json::json!({ + "executionId": "existing-job", "connectionId": "missing", "database": "app", + "filePath": copy, "continueOnError": false + })) + .unwrap(); + assert!(execute_sql_file(State(state.clone()), Json(SqlFileExecuteWrapper { request, file_paths: vec![] })) + .await + .is_err()); + assert!(!copy.exists()); + assert!(source.exists()); + assert!(!token.is_cancelled()); + assert!(state.sql_file_executions.read().await.contains_key("existing-job")); + } #[test] fn sql_file_upload_limit_defaults_and_clamps() { diff --git a/crates/dbx-web/src/state.rs b/crates/dbx-web/src/state.rs index 1e728def52..ca9c23df85 100644 --- a/crates/dbx-web/src/state.rs +++ b/crates/dbx-web/src/state.rs @@ -39,6 +39,7 @@ pub struct WebState { pub transfer_progress_channels: RwLock>>, pub table_import_channels: RwLock>>, pub sql_file_executions: RwLock>, + pub managed_sql_previews: crate::routes::sql_file::ManagedSqlPreviews, pub nacos_imports: RwLock>, pub login_rate_limit: Mutex, /// Completed Web export temp files waiting for the browser download. @@ -66,6 +67,7 @@ impl WebState { transfer_progress_channels: RwLock::new(HashMap::new()), table_import_channels: RwLock::new(HashMap::new()), sql_file_executions: RwLock::new(HashMap::new()), + managed_sql_previews: Default::default(), nacos_imports: RwLock::new(HashMap::new()), login_rate_limit: Mutex::new(LoginRateLimit { fail_count: 0, locked_until: None }), export_files: RwLock::new(HashMap::new()), diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 729a69b9a7..b3295bc36e 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -115,6 +115,7 @@ COPY --from=backend /out/${TARGETPLATFORM}/dbx-web /usr/local/bin/ COPY --from=frontend /app/dist /app/static ENV DBX_STATIC_DIR=/app/static ENV DBX_DATA_DIR=/app/data +ENV DBX_BACKUP_ROOT=/app/backups ENV DBX_JAVA_BIN=/usr/bin/java EXPOSE 4224 CMD ["dbx-web"] diff --git a/deploy/docker-compose.release.yml b/deploy/docker-compose.release.yml index ca1d6d584f..9692e3cccc 100644 --- a/deploy/docker-compose.release.yml +++ b/deploy/docker-compose.release.yml @@ -8,14 +8,18 @@ services: - "4224:4224" volumes: - dbx-data:/app/data - # environment: - # DBX_SECRET_KEY_FILE: /run/secrets/dbx_secret_key + - dbx-backups:/app/backups + environment: + DBX_BACKUP_ROOT: /app/backups + # DBX_SECRET_KEY_FILE: /run/secrets/dbx_secret_key # secrets: # - dbx_secret_key + stop_grace_period: 90s restart: unless-stopped volumes: dbx-data: + dbx-backups: # secrets: # dbx_secret_key: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 0db5dc6fb4..8f06b40937 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -7,6 +7,7 @@ services: - "4224:4224" environment: - DBX_PASSWORD=changeme + - DBX_BACKUP_ROOT=/app/backups # Native Streamable HTTP MCP is served by this same Web listener at /mcp. # Set the host authority clients use (including its externally mapped port). # - DBX_WEB_MCP_TOKEN=replace-with-a-long-random-secret @@ -21,10 +22,13 @@ services: # - dbx_secret_key volumes: - dbx-data:/app/data + - dbx-backups:/app/backups + stop_grace_period: 90s restart: unless-stopped volumes: dbx-data: + dbx-backups: # secrets: # dbx_secret_key: diff --git a/docs/background-database-backups.md b/docs/background-database-backups.md new file mode 100644 index 0000000000..76169c957e --- /dev/null +++ b/docs/background-database-backups.md @@ -0,0 +1,130 @@ +# Background Database Backups + +DBX schedules MySQL and PostgreSQL SQL backups in a shared Rust worker. The +desktop window and browser display persisted state; neither needs to keep a +JavaScript timer alive. Backup options, table filters, gzip compression, +per-database snapshots, cancellation, and successful-run retention also apply +to background runs. + +## Desktop + +Open **Settings > Database Backup**. Enable **Background backups after closing +DBX** to continue after quitting the application. Without this option, closing +DBX stops its worker and cancels any active backup. With it enabled, an active +backup continues in the same independent process after the window closes. + +| Platform | Automatic startup | Default lifetime | +| --- | --- | --- | +| Windows | Per-user Task Scheduler task `dbx-backup-` | While the user is signed in | +| macOS | `~/Library/LaunchAgents/app.dbx.dbx-backup-.plist` | While the user is signed in | +| Linux | `systemd --user` unit `dbx-backup-.service` | While the user manager is running | + +No administrator service is installed automatically. A powered-off or sleeping +computer cannot run backups. Login agents are not a promise of execution after +logout. On Linux, administrators can enable lingering for the service account +with `loginctl enable-linger USER`. For unattended hosts, the Web/container +deployment is usually simpler than keeping a desktop login session alive. + +The background toggle removes automatic startup when disabled. Disable it before +uninstalling DBX or moving a portable executable. Opening DBX again refreshes the +registered executable path. Existing backup SQL files are never removed by +disabling the worker or deleting a schedule. + +For a custom process supervisor (including non-systemd Linux), the installed +desktop executable has a headless entrypoint: + +```text +dbx --backup-worker --data-dir /absolute/path/to/existing/dbx-data +``` + +Windows PowerShell example: + +```powershell +& 'G:\Applications\DBX\dbx.exe' --backup-worker --data-dir 'G:\DBX-data' +``` + +Use the existing data directory containing `dbx.db`, and the same account that +saved the connections. The headless entrypoint does not initialize a WebView or +open an HTTP port. Run one supervised worker per data directory; a filesystem +lock also prevents an overlapping desktop/helper instance from executing the +same queue. Do not place the SQLite state database on a network filesystem or +share it between independent hosts as a high-availability scheduler. + +## Web And Containers + +The `dbx-web` process executes schedules whether browsers are connected or not. +In **Settings > Database Backup**, destinations are server paths, not paths on +the browser's computer. `DBX_BACKUP_ROOT` restricts all destinations to a +server-owned directory. Without this variable, standalone Web uses +`DBX_DATA_DIR/backups`. The Docker image uses `/app/backups`. + +Persist both application data and backup output: + +```yaml +services: + dbx: + image: t8y2/dbx:latest + ports: + - "4224:4224" + environment: + DBX_BACKUP_ROOT: /app/backups + volumes: + - dbx-data:/app/data + - dbx-backups:/app/backups + restart: unless-stopped + stop_grace_period: 90s + +volumes: + dbx-data: + dbx-backups: +``` + +Create any additional destination subdirectories inside the mounted root on the +server before selecting them. The root must be writable by the DBX service +account. Keep Web authentication enabled. Download and restore operations use +the same authenticated session as other DBX APIs. Restoration copies the chosen +backup into DBX's temporary SQL-file workspace and then uses the existing SQL +execution confirmation dialog; it does not modify the retained backup. + +Use a single container replica for each data volume. Container restart and +browser refresh do not erase schedules. SIGTERM requests cancellation and +persists the outcome before shutdown; allow the configured grace period. + +## Timing, Credentials, And Recovery + +- Each schedule stores an IANA time zone. Legacy desktop schedules migrate with + the browser's current zone, retaining the old storage keys as a fallback. +- Hourly schedules use elapsed time. Daily and weekly schedules use the saved + zone. A repeated autumn time runs once at its first occurrence; a missing + spring-forward time runs at the first valid minute after the gap. +- After downtime, one catch-up job is queued, rather than replaying every missed + interval. An already active schedule is never queued again. +- Only connection IDs are stored in backup plans. Every job reloads saved + credentials from the existing connection store and uses separate connection + pools. Temporary connections and unsaved passwords cannot run unattended. + SSH keys, certificates, tunnels, and destination volumes must be available to + the worker's account without an interactive prompt. +- On an unclean worker exit, queued jobs remain queued. In-progress jobs become + failed with an interruption message. Files recorded before the interruption + remain visible as potentially incomplete; they are not reported as successful. +- Retention deletes only recorded files of older successful runs of that plan. + Failed runs remain available for diagnosis and manual cleanup. Custom filename + collisions fail instead of overwriting an earlier backup. +- State lives in `database-backups/state.db` alongside `dbx.db`. Back up both + databases together with the connection store when migrating a DBX instance. +- Headless worker warnings are recorded in `database-backups/worker.log`. + Files whose creation could not be confirmed after a crash require manual + inspection before deletion; DBX does not delete an unverified file automatically. + +## Verification + +```text +cargo test -p dbx-core --no-default-features --features sqlite-bundled --lib scheduled_backup +pnpm exec vitest run apps/desktop/src/composables/__tests__/useScheduledDatabaseBackups.spec.ts apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts apps/desktop/src/lib/__tests__/backup/scheduledDatabaseBackup.spec.ts +``` + +The ignored MySQL integration test uses `DBX_LIVE_SQL_FILE_MYSQL_HOST`, `PORT`, +`USER`, and `PASSWORD` environment variables. It creates a UUID-named test +database, exercises a saved connection, filtered gzip backups and retention, and +drops only that test database afterward. Enable it explicitly with +`-- --include-ignored` on a disposable/local endpoint. diff --git a/packages/app-tests/scheduledDatabaseBackup.test.ts b/packages/app-tests/scheduledDatabaseBackup.test.ts index 89a229c47e..daf09427f1 100644 --- a/packages/app-tests/scheduledDatabaseBackup.test.ts +++ b/packages/app-tests/scheduledDatabaseBackup.test.ts @@ -346,26 +346,7 @@ test("scheduled backup history translates stable backend errors inline", () => { assert.doesNotMatch(source, /\{\{ run\.error \}\}/); }); -test("scheduled backup history exposes rename and overall percentage controls", () => { - const source = readFileSync("apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue", "utf8"); - const scheduler = readFileSync("apps/desktop/src/composables/useScheduledDatabaseBackups.ts", "utf8"); - - assert.match(source, /run\.displayName \|\| run\.scheduleName/); - assert.match(source, /role="progressbar"/); - assert.match(scheduler, /databaseBackupConnectionQueue\.run\(config\.connectionId/); - assert.match(scheduler, /status: databaseBackupAggregateExportStatus\(progress\.status, false\)/); - assert.match(scheduler, /overallPercent: progressPercent/); -}); - -test("scheduled backups prepare table scope before opening a consistent snapshot", () => { - const scheduler = readFileSync("apps/desktop/src/composables/useScheduledDatabaseBackups.ts", "utf8"); +test("database exports keep the backup snapshot alive", () => { const exportCore = readFileSync("crates/dbx-core/src/data/database_export.rs", "utf8"); - const schemaIndex = scheduler.indexOf("await api.listSchemas(config.connectionId, database)"); - const snapshotIndex = scheduler.indexOf("await api.beginDatabaseBackupSnapshot(config.connectionId, database, runId)"); - const exportIndex = scheduler.indexOf("await runDatabaseExportUntilTerminal("); - - assert.ok(schemaIndex >= 0); - assert.ok(snapshotIndex > schemaIndex); - assert.ok(exportIndex > snapshotIndex); assert.match(exportCore, /keep_manual_transaction_alive\(state, snapshot_session_id\)\.await/); }); diff --git a/src-tauri/src/background_backup.rs b/src-tauri/src/background_backup.rs new file mode 100644 index 0000000000..f034b78311 --- /dev/null +++ b/src-tauri/src/background_backup.rs @@ -0,0 +1,515 @@ +use dbx_core::{ + connection::AppState, + scheduled_backup::{BackupCommand, BackupService}, + storage::Storage, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::{ + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::Arc, + time::Duration, +}; +use tokio_util::sync::CancellationToken; + +pub struct BackgroundBackup { + pub service: BackupService, + data_dir: PathBuf, + stop: CancellationToken, + worker: tokio::sync::Mutex>>, + lease: PathBuf, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackgroundStatus { + enabled: bool, + platform: &'static str, +} + +impl BackgroundBackup { + pub fn new(state: Arc, data_dir: PathBuf) -> Result { + let service = BackupService::new(state, &data_dir, None); + let stop = CancellationToken::new(); + let directory = data_dir.join("database-backups"); + std::fs::create_dir_all(&directory).map_err(|e| e.to_string())?; + let lease = directory.join(format!("ui-{}.lease", uuid::Uuid::new_v4().simple())); + std::fs::write(&lease, b"1").map_err(|e| e.to_string())?; + let mut command = Command::new(std::env::current_exe().map_err(|e| e.to_string())?); + command + .arg("--ui-backup-worker") + .arg("--data-dir") + .arg(&data_dir) + .arg("--ui-lease") + .arg(&lease) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000200); + } + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let mut child = command.spawn().map_err(|e| e.to_string())?; + let heartbeat_lease = lease.clone(); + let heartbeat_stop = stop.clone(); + let worker = tokio::spawn(async move { + loop { + tokio::select! { _ = heartbeat_stop.cancelled() => break, _ = tokio::time::sleep(Duration::from_secs(2)) => {} } + if let Err(error) = tokio::fs::write(&heartbeat_lease, b"1").await { + log::error!("[database-backup] UI lease failed: {error}"); + } + if child.try_wait().is_ok_and(|exit| exit.is_some()) { + match command.spawn() { + Ok(restarted) => child = restarted, + Err(error) => log::error!("[database-backup] worker restart failed: {error}"), + } + } + } + }); + Ok(Self { service, data_dir, stop, worker: tokio::sync::Mutex::new(Some(worker)), lease }) + } + + pub async fn shutdown(&self) { + self.stop.cancel(); + if let Some(worker) = self.worker.lock().await.take() { + let _ = tokio::time::timeout(Duration::from_secs(60), worker).await; + } + let _ = tokio::fs::remove_file(&self.lease).await; + } + + pub fn resume(&self) -> Result<(), String> { + resume_registration(&self.data_dir, register) + } +} + +fn resume_registration(data_dir: &Path, register: impl FnOnce(&Path) -> Result<(), String>) -> Result<(), String> { + if marker(data_dir).exists() { + register(data_dir)?; + } + Ok(()) +} + +#[tauri::command] +pub async fn database_backup_command( + state: tauri::State<'_, BackgroundBackup>, + command: BackupCommand, +) -> Result { + state.service.command(command).await +} + +#[tauri::command] +pub async fn database_backup_background( + state: tauri::State<'_, BackgroundBackup>, + enabled: Option, +) -> Result { + let data_dir = state.data_dir.clone(); + tokio::task::spawn_blocking(move || { + if let Some(enabled) = enabled { + if enabled { + std::fs::create_dir_all(data_dir.join("database-backups")).map_err(|e| e.to_string())?; + std::fs::write(marker(&data_dir), b"1").map_err(|e| e.to_string())?; + if let Err(error) = register(&data_dir) { + let _ = std::fs::remove_file(marker(&data_dir)); + return Err(error); + } + } else { + match std::fs::remove_file(marker(&data_dir)) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.to_string()), + } + unregister(&data_dir)?; + } + } + Ok(BackgroundStatus { enabled: marker(&data_dir).exists(), platform: std::env::consts::OS }) + }) + .await + .map_err(|e| e.to_string())? +} + +fn marker(data_dir: &Path) -> PathBuf { + data_dir.join("database-backups").join("background-enabled") +} +fn name(data_dir: &Path) -> String { + let hash = format!("{:x}", Sha256::digest(data_dir.to_string_lossy().as_bytes())); + format!("dbx-backup-{}", &hash[..16]) +} + +fn run(command: &mut Command) -> Result<(), String> { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x08000000); + } + let output = command.stdin(Stdio::null()).output().map_err(|e| e.to_string())?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "Background service command failed: {} {}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + )) + } +} + +#[cfg(any(windows, target_os = "macos"))] +fn xml(value: &str) -> String { + value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """).replace('\'', "'") +} + +struct WorkerLogger(std::sync::Mutex); + +impl log::Log for WorkerLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= log::Level::Warn + } + fn log(&self, record: &log::Record<'_>) { + use std::io::Write; + if self.enabled(record.metadata()) { + if let Ok(mut file) = self.0.lock() { + let _ = writeln!(file, "{} {} {}", chrono::Utc::now().to_rfc3339(), record.level(), record.args()); + } + } + } + fn flush(&self) { + use std::io::Write; + if let Ok(mut file) = self.0.lock() { + let _ = file.flush(); + } + } +} + +fn worker_logging(data_dir: &Path) -> Result<(), String> { + let directory = data_dir.join("database-backups"); + std::fs::create_dir_all(&directory).map_err(|e| e.to_string())?; + let path = directory.join("worker.log"); + let truncate = std::fs::metadata(&path).is_ok_and(|metadata| metadata.len() > 5 * 1024 * 1024); + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .append(!truncate) + .truncate(truncate) + .open(path) + .map_err(|e| e.to_string())?; + if log::set_logger(Box::leak(Box::new(WorkerLogger(std::sync::Mutex::new(file))))).is_ok() { + log::set_max_level(log::LevelFilter::Warn); + } + Ok(()) +} + +#[cfg(windows)] +fn current_user_sid() -> Result { + use std::os::windows::process::CommandExt; + let output = Command::new("whoami.exe") + .args(["/user", "/fo", "csv", "/nh"]) + .creation_flags(0x08000000) + .stdin(Stdio::null()) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err("Cannot resolve the Windows backup account".into()); + } + let mut reader = csv::ReaderBuilder::new().has_headers(false).from_reader(output.stdout.as_slice()); + // The account name uses the console code page; only the SID is guaranteed ASCII. + let row = reader.byte_records().next().ok_or("Windows account is unavailable")?.map_err(|e| e.to_string())?; + let sid = + std::str::from_utf8(row.get(1).ok_or("Windows account SID is unavailable")?).map_err(|e| e.to_string())?; + if !sid.starts_with("S-1-") || !sid.split('-').skip(1).all(|part| part.parse::().is_ok()) { + return Err("Invalid Windows account SID".into()); + } + Ok(sid.to_string()) +} + +#[cfg(windows)] +fn register(data_dir: &Path) -> Result<(), String> { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let user_id = current_user_sid()?; + let task = name(data_dir); + let path = data_dir.join("database-backups").join("task.xml"); + let path_text = data_dir.to_string_lossy(); + let trailing_slashes = path_text.chars().rev().take_while(|c| *c == '\\').count(); + let arguments = format!("--managed-backup-worker --data-dir \"{}{}\"", path_text, "\\".repeat(trailing_slashes)); + let content = format!( + r#" +true{user_id}{user_id}InteractiveTokenLeastPrivilegeIgnoreNewfalsefalsePT0SPT1M3{}{}"#, + xml(&exe.to_string_lossy()), + xml(&arguments) + ); + // schtasks reads XML reliably as BOM-prefixed UTF-16, including non-ASCII paths. + let encoded: Vec = + std::iter::once(0xfeff_u16).chain(content.encode_utf16()).flat_map(u16::to_le_bytes).collect(); + std::fs::write(&path, encoded).map_err(|e| e.to_string())?; + run(Command::new("schtasks.exe").args(["/Create", "/F", "/TN", &task, "/XML"]).arg(&path))?; + run(Command::new("schtasks.exe").args(["/Run", "/TN", &task])) +} + +#[cfg(windows)] +fn unregister(data_dir: &Path) -> Result<(), String> { + run(Command::new("schtasks.exe").args(["/Delete", "/F", "/TN", &name(data_dir)])) +} + +#[cfg(target_os = "macos")] +fn launch_agent(data_dir: &Path) -> Result<(String, PathBuf, String), String> { + let label = format!("app.dbx.{}", name(data_dir)); + let home = std::env::var_os("HOME").ok_or("HOME is unavailable")?; + let path = PathBuf::from(home).join("Library/LaunchAgents").join(format!("{label}.plist")); + let output = Command::new("id").arg("-u").output().map_err(|e| e.to_string())?; + let uid = String::from_utf8(output.stdout).map_err(|e| e.to_string())?.trim().to_string(); + if !output.status.success() || !uid.chars().all(|c| c.is_ascii_digit()) || uid.is_empty() { + return Err("Cannot resolve login user".into()); + } + Ok((label, path, format!("gui/{uid}"))) +} + +#[cfg(target_os = "macos")] +fn register(data_dir: &Path) -> Result<(), String> { + let (label, path, domain) = launch_agent(data_dir)?; + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let content = format!( + r#"Label{}ProgramArguments{}--managed-backup-worker--data-dir{}RunAtLoadKeepAliveSuccessfulExitThrottleInterval30"#, + xml(&label), + xml(&exe.to_string_lossy()), + xml(&data_dir.to_string_lossy()) + ); + std::fs::create_dir_all(path.parent().ok_or("Invalid LaunchAgent path")?).map_err(|e| e.to_string())?; + std::fs::write(&path, content).map_err(|e| e.to_string())?; + if Command::new("launchctl") + .args(["print", &format!("{domain}/{label}")]) + .output() + .is_ok_and(|o| o.status.success()) + { + return Ok(()); + } + run(Command::new("launchctl").args(["bootstrap", &domain]).arg(&path)) +} + +#[cfg(target_os = "macos")] +fn unregister(data_dir: &Path) -> Result<(), String> { + let (_, path, domain) = launch_agent(data_dir)?; + run(Command::new("launchctl").args(["bootout", &domain]).arg(&path))?; + std::fs::remove_file(path).map_err(|e| e.to_string()) +} + +#[cfg(target_os = "linux")] +fn unit_path(data_dir: &Path) -> Result { + let config = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + .ok_or("User configuration directory is unavailable")?; + Ok(config.join("systemd/user").join(format!("{}.service", name(data_dir)))) +} + +#[cfg(any(target_os = "linux", test))] +fn systemd_arg(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"").replace('%', "%%").replace('$', "$$")) +} + +#[cfg(any(target_os = "linux", test))] +fn service_executable(env: &tauri::Env) -> Result { + tauri::process::current_binary(env).map_err(|error| error.to_string()) +} + +#[cfg(target_os = "linux")] +fn register(data_dir: &Path) -> Result<(), String> { + let path = unit_path(data_dir)?; + let exe = service_executable(&tauri::Env::default())?; + if data_dir.to_string_lossy().chars().chain(exe.to_string_lossy().chars()).any(char::is_control) { + return Err("Service paths contain control characters".into()); + } + let content = format!("[Unit]\nDescription=DBX database backups\nAfter=network-online.target\n[Service]\nExecStart={} --managed-backup-worker --data-dir {}\nRestart=on-failure\nRestartSec=30\nTimeoutStopSec=90\n[Install]\nWantedBy=default.target\n", systemd_arg(&exe.to_string_lossy()), systemd_arg(&data_dir.to_string_lossy())); + std::fs::create_dir_all(path.parent().ok_or("Invalid systemd path")?).map_err(|e| e.to_string())?; + std::fs::write(&path, content).map_err(|e| e.to_string())?; + run(Command::new("systemctl").args(["--user", "daemon-reload"]))?; + run(Command::new("systemctl").args(["--user", "enable", "--now", &format!("{}.service", name(data_dir))])) +} + +#[derive(Debug, PartialEq, Eq)] +enum WorkerAction { + Continue, + Drain, + Stop, +} + +fn worker_action(managed: bool, ui: bool, ui_alive: bool, enabled: bool) -> WorkerAction { + if ui && !ui_alive && enabled { + WorkerAction::Drain + } else if (managed || ui) && !enabled && !(ui && ui_alive) { + WorkerAction::Stop + } else { + WorkerAction::Continue + } +} + +#[cfg(target_os = "linux")] +fn unregister(data_dir: &Path) -> Result<(), String> { + run(Command::new("systemctl").args(["--user", "disable", "--now", &format!("{}.service", name(data_dir))]))?; + std::fs::remove_file(unit_path(data_dir)?).map_err(|e| e.to_string())?; + run(Command::new("systemctl").args(["--user", "daemon-reload"])) +} + +/// This entrypoint runs before Tauri's single-instance plugin or any WebView initialization. +pub fn run_if_requested() -> bool { + let args: Vec<_> = std::env::args_os().collect(); + let managed = args.iter().any(|a| a == "--managed-backup-worker"); + let ui = args.iter().any(|a| a == "--ui-backup-worker"); + if !managed && !ui && !args.iter().any(|a| a == "--backup-worker") { + return false; + } + let result = (|| { + let index = args.iter().position(|a| a == "--data-dir").ok_or("--data-dir is required for backup workers")?; + let dir = PathBuf::from(args.get(index + 1).ok_or("--data-dir requires a path")?); + if !dir.is_absolute() || !dir.join("dbx.db").is_file() { + return Err("Worker requires an existing absolute DBX data directory".to_string()); + } + let lease = args.iter().position(|a| a == "--ui-lease").and_then(|i| args.get(i + 1)).map(PathBuf::from); + if ui && lease.is_none() { + return Err("UI worker requires a lease".into()); + } + if managed && !marker(&dir).exists() { + return Ok(()); + } + worker_logging(&dir)?; + let runtime = dbx_core::scheduled_backup::worker_runtime().map_err(|e| e.to_string())?; + let result = runtime.block_on(async { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + dbx_core::sql_dialect::dialect_loader::register_core_dialects(); + let storage = Storage::open_unmigrated(&dir.join("dbx.db")).await?; + let state = Arc::new(AppState::new_with_plugin_dir(storage, dir.join("plugins"))); + let stop = CancellationToken::new(); + let drain = CancellationToken::new(); + let service = BackupService::new(state.clone(), &dir, None); + let worker = service.start_with_drain(stop.clone(), drain.clone()); + #[cfg(unix)] + let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).map_err(|e| e.to_string())?; + loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => break, + _ = async { #[cfg(unix)] { terminate.recv().await; } #[cfg(not(unix))] { std::future::pending::<()>().await; } } => break, + _ = tokio::time::sleep(Duration::from_secs(1)) => { + if worker.is_finished() { break; } + let ui_alive = lease.as_ref().and_then(|p| std::fs::metadata(p).ok()).and_then(|m| m.modified().ok()) + .and_then(|time| time.elapsed().ok()).is_some_and(|age| age < Duration::from_secs(10)); + match worker_action(managed, ui, ui_alive, marker(&dir).exists()) { + WorkerAction::Drain => drain.cancel(), + WorkerAction::Stop => break, + WorkerAction::Continue => {} + } + } + } + } + stop.cancel(); + let _ = tokio::time::timeout(Duration::from_secs(60), worker).await; + state.shutdown(Duration::from_secs(3)).await; + Ok::<_, String>(()) + }); + // Runtime drop otherwise waits indefinitely for a stalled blocking export. + runtime.shutdown_timeout(Duration::from_secs(3)); + result + })(); + if let Err(error) = result { + log::error!("DBX backup worker: {error}"); + eprintln!("DBX backup worker: {error}"); + std::process::exit(1); + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_service_uses_the_current_binary() { + let mut env = tauri::Env::default(); + env.args_os.clear(); + #[cfg(target_os = "linux")] + { + env.appimage = None; + env.appdir = None; + } + assert_eq!(service_executable(&env).unwrap(), std::env::current_exe().unwrap().canonicalize().unwrap()); + } + + #[cfg(target_os = "linux")] + #[test] + fn appimage_service_uses_the_persistent_outer_launcher() { + let mut env = tauri::Env::default(); + env.appimage = Some("/home/user/DBX new%build.AppImage".into()); + env.appdir = Some("/tmp/.mount_DBX123".into()); + env.args_os.clear(); + let executable = service_executable(&env).unwrap(); + assert_eq!(executable, PathBuf::from("/home/user/DBX new%build.AppImage")); + assert_eq!(systemd_arg(&executable.to_string_lossy()), "\"/home/user/DBX new%%build.AppImage\""); + } + + #[test] + fn service_arguments_preserve_spaces_and_escape_systemd_expansion() { + assert_eq!(systemd_arg("/home/user/DBX $next%build.AppImage"), "\"/home/user/DBX $$next%%build.AppImage\""); + } + + #[test] + fn worker_exit_and_restart_decisions_preserve_foreground_and_managed_jobs() { + assert_eq!(worker_action(false, true, true, false), WorkerAction::Continue); + assert_eq!(worker_action(false, true, false, false), WorkerAction::Stop); + assert_eq!(worker_action(false, true, false, true), WorkerAction::Drain); + assert_eq!(worker_action(false, true, true, true), WorkerAction::Continue); + assert_eq!(worker_action(true, false, false, true), WorkerAction::Continue); + assert_eq!(worker_action(true, false, false, false), WorkerAction::Stop); + assert_eq!(worker_action(false, false, false, false), WorkerAction::Continue); + } + + #[test] + fn restart_registration_respects_the_off_state_and_preserves_enabled_state_on_failure() { + let directory = tempfile::tempdir().unwrap(); + resume_registration(directory.path(), |_| panic!("disabled service must not register")).unwrap(); + std::fs::create_dir_all(directory.path().join("database-backups")).unwrap(); + std::fs::write(marker(directory.path()), b"1").unwrap(); + let mut registrations = 0; + resume_registration(directory.path(), |data_dir| { + assert_eq!(data_dir, directory.path()); + registrations += 1; + Ok(()) + }) + .unwrap(); + assert_eq!(registrations, 1); + assert!(resume_registration(directory.path(), |_| Err("upgrade registration failed".into())).is_err()); + assert!(marker(directory.path()).exists()); + } + + #[tokio::test] + async fn shutdown_stops_the_mock_supervisor_and_removes_only_its_lease() { + let directory = tempfile::tempdir().unwrap(); + let storage = Storage::open(&directory.path().join("dbx.db")).await.unwrap(); + let state = Arc::new(AppState::new(storage)); + std::fs::create_dir_all(directory.path().join("database-backups")).unwrap(); + std::fs::write(marker(directory.path()), b"1").unwrap(); + let lease = directory.path().join("database-backups/ui-test.lease"); + std::fs::write(&lease, b"1").unwrap(); + let stop = CancellationToken::new(); + let supervisor_stop = stop.clone(); + let worker = tokio::spawn(async move { + supervisor_stop.cancelled().await; + }); + let backup = BackgroundBackup { + service: BackupService::new(state, directory.path(), None), + data_dir: directory.path().to_path_buf(), + stop: stop.clone(), + worker: tokio::sync::Mutex::new(Some(worker)), + lease: lease.clone(), + }; + tokio::time::timeout(Duration::from_secs(2), backup.shutdown()).await.unwrap(); + backup.shutdown().await; + assert!(stop.is_cancelled()); + assert!(backup.worker.lock().await.is_none()); + assert!(!lease.exists()); + assert!(marker(directory.path()).exists()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e94225f991..f4c7a1075c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,7 @@ +mod background_backup; mod commands; mod data_dir; +pub use background_backup::run_if_requested as run_backup_worker_if_requested; mod db; #[cfg(target_os = "macos")] mod macos_app_delegate; @@ -1438,15 +1440,11 @@ mod tests { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // Metadata/completion command chains nest very large async futures (a single - // frame can be 60-150 KiB), which can exhaust tokio's default 2 MiB worker - // stack and abort the process with STATUS_STACK_OVERFLOW. Give the runtime a - // roomier worker stack so those chains have headroom. - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .thread_stack_size(16 * 1024 * 1024) - .build() - .expect("Failed to build tokio runtime"); + // Metadata/completion command chains nest very large async futures and can + // exhaust tokio's default 2 MiB worker stack, which aborts the process with + // STATUS_STACK_OVERFLOW. Share the roomier stack the backup worker and Web + // server runtimes use as well. + let runtime = dbx_core::scheduled_backup::worker_runtime().expect("Failed to build tokio runtime"); let runtime_handle = runtime.handle().clone(); let _runtime = Box::leak(Box::new(runtime)); tauri::async_runtime::set(runtime_handle); @@ -1671,6 +1669,18 @@ pub fn run() { let state = Arc::new(state); app.manage(state.clone()); commands::plugins::install_plugin_event_bridge(app.handle(), state.clone()); + let backups = tauri::async_runtime::block_on(async { + background_backup::BackgroundBackup::new(state.clone(), data_dir.clone()) + }); + match backups { + Ok(backups) => { + if let Err(error) = backups.resume() { + log::error!("[database-backup] background registration failed: {error}"); + } + app.manage(backups); + } + Err(error) => log::error!("[database-backup] worker startup failed: {error}"), + } let mcp_http_server = Arc::new(commands::mcp_http_server::McpHttpServerState::new(data_dir.clone())); app.manage(mcp_http_server.clone()); let mcp_http_state = state.clone(); @@ -2347,6 +2357,8 @@ pub fn run() { commands::fs_open::reveal_path_in_file_manager, commands::fs_open::is_sqlite_database_file, commands::fs_open::delete_database_backup_files, + background_backup::database_backup_command, + background_backup::database_backup_background, commands::sqlite_backup::backup_sqlite_database, commands::sqlite_backup::restore_sqlite_database, commands::mongo_cmd::mongo_list_databases, @@ -2708,6 +2720,9 @@ pub fn run() { } } tauri::async_runtime::block_on(async { + if let Some(backups) = app_handle.try_state::() { + backups.shutdown().await; + } if let Some(server) = app_handle.try_state::() { server.shutdown(Duration::from_secs(1)).await; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1e0016908e..d66c4e0a54 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,8 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + if dbx_lib::run_backup_worker_if_requested() { + return; + } dbx_lib::run(); }