From 2556070b9b365a7593024153351b077e17cd3bdf Mon Sep 17 00:00:00 2001 From: huyplb Date: Sun, 4 Oct 2026 16:23:51 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat(utilities):=20Backup=20&=20Restore=20?= =?UTF-8?q?=E2=80=94=20commands=20per=20engine,=20with=20saved=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new Utilities tool that writes the backup and restore commands for the chosen connection. Fox Schema writes them and does not run them. - Where the command runs comes first, because it decides what "folder" means: pg_dump writes where it runs, SQL Server's BACKUP DATABASE on the database server, Oracle Data Pump into a DIRECTORY object, Redshift keeps a snapshot in AWS. - Every engine Fox Schema connects to (16): pg_dump/pg_restore, ysql_dump, CockroachDB BACKUP/RESTORE, Redshift snapshots via the AWS CLI, mysqldump / mariadb-dump (TiDB without routines), SQL Server BACKUP DATABASE (COPY_ONLY), Azure SQL SqlPackage, Oracle expdp/impdp, Db2 BACKUP/RESTORE and db2look, ClickHouse BACKUP to a disk, DuckDB EXPORT, sqlite3 .backup/.dump, mongodump, redis-cli --rdb. - Options per engine: folder, format, what to back up (everything, structure only (DDL), rows only), compression, only the connection's schema, only some tables. Restores that would overwrite live data say so; CockroachDB, ClickHouse and Azure SQL restore beside it under a new name. - No command contains a password; each says how its tool asks for one. - Folder, format and scope save as the user's default per engine: metadata migration 26 (backup_settings), GET/PUT /api/backup-settings, re-validated on every read. Pure command generation in @foxschema/sql (one provider file per engine, registry, facade); the server stores defaults; the panel is features/utilities/components/BackupRestorePanel.tsx. Co-Authored-By: Claude Opus 5.5 --- apps/e2e/package.json | 1 + apps/e2e/scripts/run-all.mjs | 18 + apps/e2e/src/generated/test-ids.ts | 83 ++++ .../tests/backup-commands-dialects.test.ts | 127 ++++++ apps/e2e/src/tests/backup-restore-ui.test.ts | 113 +++++ .../components/BackupRestorePanel.test.tsx | 122 ++++++ .../components/BackupRestorePanel.tsx | 394 ++++++++++++++++++ .../components/UtilitiesView.test.tsx | 14 + .../utilities/components/UtilitiesView.tsx | 12 + apps/web/src/frontend/shared/api/backupApi.ts | 22 + docs/testing/TEST_IDS.md | 34 +- packages/server/src/api/http-contract.test.ts | 4 +- packages/server/src/api/server.ts | 2 + packages/server/src/database/schema.ts | 19 + .../features/backup/backup-settings.routes.ts | 36 ++ .../backup/backup-settings.service.test.ts | 56 +++ .../backup/backup-settings.service.ts | 60 +++ packages/sql/src/index.ts | 20 + .../src/modules/utilities/backup-helpers.ts | 42 ++ .../src/modules/utilities/backup.registry.ts | 48 +++ .../sql/src/modules/utilities/backup.test.ts | 270 ++++++++++++ packages/sql/src/modules/utilities/backup.ts | 121 ++++++ .../sql/src/modules/utilities/backup.types.ts | 100 +++++ .../src/providers/azureSql/azuresql.backup.ts | 61 +++ .../providers/clickHouse/clickhouse.backup.ts | 55 +++ .../cockroachDb/cockroachdb.backup.ts | 59 +++ packages/sql/src/providers/db2/db2.backup.ts | 59 +++ .../sql/src/providers/duckDb/duckdb.backup.ts | 45 ++ .../src/providers/mariaDb/mariadb.backup.ts | 16 + .../src/providers/mongodb/mongodb.backup.ts | 46 ++ .../sql/src/providers/mysql/mysql.backup.ts | 77 ++++ .../sql/src/providers/oracle/oracle.backup.ts | 55 +++ .../src/providers/postgres/postgres.backup.ts | 99 +++++ .../sql/src/providers/redis/redis.backup.ts | 47 +++ .../src/providers/redshift/redshift.backup.ts | 55 +++ .../src/providers/sqlLite/sqlite.backup.ts | 57 +++ .../providers/sqlServer/sqlserver.backup.ts | 54 +++ .../sql/src/providers/tiDb/tidb.backup.ts | 17 + .../providers/yugabyteDb/yugabytedb.backup.ts | 17 + 39 files changed, 2535 insertions(+), 2 deletions(-) create mode 100644 apps/e2e/src/tests/backup-commands-dialects.test.ts create mode 100644 apps/e2e/src/tests/backup-restore-ui.test.ts create mode 100644 apps/web/src/frontend/features/utilities/components/BackupRestorePanel.test.tsx create mode 100644 apps/web/src/frontend/features/utilities/components/BackupRestorePanel.tsx create mode 100644 apps/web/src/frontend/shared/api/backupApi.ts create mode 100644 packages/server/src/features/backup/backup-settings.routes.ts create mode 100644 packages/server/src/features/backup/backup-settings.service.test.ts create mode 100644 packages/server/src/features/backup/backup-settings.service.ts create mode 100644 packages/sql/src/modules/utilities/backup-helpers.ts create mode 100644 packages/sql/src/modules/utilities/backup.registry.ts create mode 100644 packages/sql/src/modules/utilities/backup.test.ts create mode 100644 packages/sql/src/modules/utilities/backup.ts create mode 100644 packages/sql/src/modules/utilities/backup.types.ts create mode 100644 packages/sql/src/providers/azureSql/azuresql.backup.ts create mode 100644 packages/sql/src/providers/clickHouse/clickhouse.backup.ts create mode 100644 packages/sql/src/providers/cockroachDb/cockroachdb.backup.ts create mode 100644 packages/sql/src/providers/db2/db2.backup.ts create mode 100644 packages/sql/src/providers/duckDb/duckdb.backup.ts create mode 100644 packages/sql/src/providers/mariaDb/mariadb.backup.ts create mode 100644 packages/sql/src/providers/mongodb/mongodb.backup.ts create mode 100644 packages/sql/src/providers/mysql/mysql.backup.ts create mode 100644 packages/sql/src/providers/oracle/oracle.backup.ts create mode 100644 packages/sql/src/providers/postgres/postgres.backup.ts create mode 100644 packages/sql/src/providers/redis/redis.backup.ts create mode 100644 packages/sql/src/providers/redshift/redshift.backup.ts create mode 100644 packages/sql/src/providers/sqlLite/sqlite.backup.ts create mode 100644 packages/sql/src/providers/sqlServer/sqlserver.backup.ts create mode 100644 packages/sql/src/providers/tiDb/tidb.backup.ts create mode 100644 packages/sql/src/providers/yugabyteDb/yugabytedb.backup.ts diff --git a/apps/e2e/package.json b/apps/e2e/package.json index 2345e1591..709521aac 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -13,6 +13,7 @@ "test:headed": "HEADLESS=false vitest run --config vitest.config.ts", "test:smoke": "vitest run --config vitest.config.ts src/tests/smoke.test.ts", "test:git": "vitest run --config vitest.config.ts src/tests/git-migrations.test.ts", + "test:backup": "vitest run --config vitest.config.ts src/tests/backup-restore-ui.test.ts src/tests/backup-commands-dialects.test.ts", "test:smoke:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/smoke.test.ts", "test:auth": "vitest run --config vitest.config.ts src/tests/auth-recovery.test.ts", "test:auth:headed": "HEADLESS=false vitest run --config vitest.config.ts src/tests/auth-recovery.test.ts", diff --git a/apps/e2e/scripts/run-all.mjs b/apps/e2e/scripts/run-all.mjs index 9fba131e5..e7f49d12b 100644 --- a/apps/e2e/scripts/run-all.mjs +++ b/apps/e2e/scripts/run-all.mjs @@ -179,6 +179,24 @@ const ALWAYS = [ // the teammate's run); the 300s default would kill it mid-run. timeoutMs: 720_000, }, + // Backup & Restore: the panel against a saved Postgres connection + // (and SQL Server's SQL editor hand-off when configured). + { + key: 'backup-ui', + file: 'src/tests/backup-restore-ui.test.ts', + label: 'Backup & Restore', + }, + ] + : []), + // The SQL-language backups (SQL Server, CockroachDB, ClickHouse, DuckDB), + // run for real against whichever of those engines are configured. + ...(configured.length + ? [ + { + key: 'backup-commands', + file: 'src/tests/backup-commands-dialects.test.ts', + label: 'Backup commands', + }, ] : []), ]; diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index 2dc9983b3..687f810be 100644 --- a/apps/e2e/src/generated/test-ids.ts +++ b/apps/e2e/src/generated/test-ids.ts @@ -2610,6 +2610,60 @@ export const TestIds = { }, }, utilities: { + BackupRestorePanel: { + /** section · Backup */ + backupCommand: 'backup-command', + /** button · Backup */ + backupCommandCopy: 'backup-command-copy', + /** button · Open in SQL Editor */ + backupCommandOpenSql: 'backup-command-open-sql', + /** pre · Backup */ + backupCommandText: 'backup-command-text', + /** input */ + backupCompress: 'backup-compress', + /** p */ + backupError: 'backup-error', + /** input */ + backupFileName: 'backup-file-name', + /** input */ + backupFolder: 'backup-folder', + /** div */ + backupFormat: 'backup-format', + /** button */ + backupFormatForValue: (value: string | number) => `backup-format-${value}`, + /** input */ + backupLimitSchema: 'backup-limit-schema', + /** div · Choose a connection */ + backupNoConnection: 'backup-no-connection', + /** ul */ + backupNotes: 'backup-notes', + /** div */ + backupPanel: 'backup-panel', + /** button */ + backupReset: 'backup-reset', + /** div */ + backupRunsOn: 'backup-runs-on', + /** button · Save as my default for */ + backupSaveDefault: 'backup-save-default', + /** span */ + backupSaveStatus: 'backup-save-status', + /** div */ + backupScope: 'backup-scope', + /** button */ + backupScopeForValue: (value: string | number) => `backup-scope-${value}`, + /** textarea · orders, customers */ + backupTables: 'backup-tables', + /** div · No backup commands */ + backupUnsupported: 'backup-unsupported', + /** section · Restore */ + restoreCommand: 'restore-command', + /** button · Restore */ + restoreCommandCopy: 'restore-command-copy', + /** button · Open in SQL Editor */ + restoreCommandOpenSql: 'restore-command-open-sql', + /** pre · Restore */ + restoreCommandText: 'restore-command-text', + }, CloneTableModal: { /** button · Apply clone */ cloneApply: 'clone-apply', @@ -2895,6 +2949,8 @@ export const TestIds = { indexManagementModal: 'index-management-modal', /** nav · Database utilities */ sqlSidebarUtilities: 'sql-sidebar-utilities', + /** data · Backup & Restore */ + utilitiesBackup: 'utilities-backup', /** data · Clone Table */ utilitiesCloneTable: 'utilities-clone-table', /** select */ @@ -3392,6 +3448,28 @@ export type TestId = | `auth-sso-buttons-continue-${string}` | 'backend-offline-banner' | 'backend-offline-retry' + | 'backup-command' + | 'backup-command-copy' + | 'backup-command-open-sql' + | 'backup-command-text' + | 'backup-compress' + | 'backup-error' + | 'backup-file-name' + | 'backup-folder' + | 'backup-format' + | `backup-format-${string}` + | 'backup-limit-schema' + | 'backup-no-connection' + | 'backup-notes' + | 'backup-panel' + | 'backup-reset' + | 'backup-runs-on' + | 'backup-save-default' + | 'backup-save-status' + | 'backup-scope' + | `backup-scope-${string}` + | 'backup-tables' + | 'backup-unsupported' | 'blueprint-add-column' | 'blueprint-add-fk' | 'blueprint-add-index' @@ -4105,6 +4183,10 @@ export type TestId = | 'report-table' | 'report-tiles' | 'report-who' + | 'restore-command' + | 'restore-command-copy' + | 'restore-command-open-sql' + | 'restore-command-text' | `rf-kinds-${string}` | `rf-object-${string}-${string}` | `rf-version-${string}` @@ -4577,6 +4659,7 @@ export type TestId = | 'user-unsupported' | 'user-valid-until' | 'user-warnings' + | 'utilities-backup' | 'utilities-clone-table' | 'utilities-connection' | 'utilities-connection-pool' diff --git a/apps/e2e/src/tests/backup-commands-dialects.test.ts b/apps/e2e/src/tests/backup-commands-dialects.test.ts new file mode 100644 index 000000000..386d71b08 --- /dev/null +++ b/apps/e2e/src/tests/backup-commands-dialects.test.ts @@ -0,0 +1,127 @@ +/** + * Backup & Restore · the SQL-language engines, run for real. + * + * Fox Schema writes backup commands and never runs them, so nothing in the + * product finds out whether an engine accepts them. For the engines whose + * backup *is* SQL — SQL Server, CockroachDB, ClickHouse, DuckDB — this runs the + * generated statements through the product's own SQL runner, against the + * configured databases: + * + * - SQL Server backs up into its data folder, then RESTORE VERIFYONLY reads + * the file back. The generated restore itself would overwrite the test + * database, so it is not run; VERIFYONLY proves the file and the RESTORE + * grammar without that. + * - CockroachDB backs up and restores under a new name, which is dropped. + * - ClickHouse and DuckDB run when configured. A server without a backup + * disk is an environment answer, reported as a skip, never as a pass. + * + * The shell-tool engines (pg_dump, mysqldump, Data Pump…) are checked by the + * unit tests and by hand; this suite holds only what the SQL runner can run. + */ +import fs from 'node:fs'; +import { afterAll, describe, expect, it } from 'vitest'; +import { buildBackupCommands, type BackupCommands } from '@foxschema/sql'; +import { getSourceConfig, hasConfig } from '../helpers/db-config.js'; +import { isEnvironmentFailure, runStatements, tryCleanup } from '../helpers/sql-exec.js'; + +/** Answers that are about this server's setup, not the statement. */ +const NOT_ABOUT_THE_BACKUP = [ + /not allowed for backups/i, // ClickHouse: no + /allowed_disk/i, + /Unknown disk/i, + /Operating system error/i, // SQL Server: folder missing or not writable + /Cannot open backup device/i, + /external.?io|nodelocal/i, // CockroachDB started without an external-io directory +]; +const environmental = (message: string) => isEnvironmentFailure(message) || NOT_ABOUT_THE_BACKUP.some((re) => re.test(message)); + +/** A folder each dev container already has. */ +const FOLDER: Record = { + sqlserver: '/var/opt/mssql/data', + cockroachdb: 'nodelocal://1/fox-e2e-backups', + clickhouse: 'backups', + duckdb: '/tmp', +}; + +const NOW = new Date(); +const cleanups: Array<() => Promise> = []; +afterAll(async () => { + for (const cleanup of cleanups) await cleanup(); +}); + +function commandsFor(dialect: string): BackupCommands { + const cfg = getSourceConfig(dialect)!; + const result = buildBackupCommands( + { dialect, host: cfg.host, port: cfg.port, database: cfg.database, schema: cfg.schema, username: cfg.username }, + { folder: FOLDER[dialect], fileName: `fox_e2e_${NOW.getTime()}` }, + NOW + ); + if ('error' in result) throw new Error(result.error); + return result; +} + +/** Statements in a generated script, one per `;`-terminated line group. */ +const statementsOf = (script: string) => + script + .split(/;\s*(?:\n|$)/) + .map((s) => s.trim()) + .filter(Boolean); + +/** Run statements in order; undefined when they all ran, the first real error otherwise, or a skip. */ +async function run(dialect: string, statements: string[]): Promise<{ rejected?: string; skipped?: string }> { + const results = await runStatements(dialect, statements); + for (let i = 0; i < statements.length; i++) { + const r = results[i]; + if (r?.ok) continue; + const message = r?.error ?? 'no result'; + if (environmental(message)) return { skipped: `${dialect}: ${message.split('\n')[0]!.slice(0, 200)}` }; + return { rejected: `${dialect} rejected:\n ${statements[i]}\n ${message.split('\n')[0]!.slice(0, 300)}` }; + } + return {}; +} + +describe.skipIf(!hasConfig('sqlserver'))('SQL Server backup', () => { + it('writes a .bak the server can read back', async () => { + const c = commandsFor('sqlserver'); + const backup = await run('sqlserver', statementsOf(c.backup)); + expect(backup.rejected).toBeUndefined(); + if (backup.skipped) return console.warn(backup.skipped); + const verify = await run('sqlserver', [`RESTORE VERIFYONLY FROM DISK = N'${c.location.replace(/'/g, "''")}'`]); + expect(verify.rejected).toBeUndefined(); + // The .bak stays in the container's data folder (SQL cannot delete it): + // docker exec foxschema-sqlserver sh -c 'rm -f /var/opt/mssql/data/fox_e2e_*.bak' + }, 120_000); +}); + +describe.skipIf(!hasConfig('cockroachdb'))('CockroachDB backup', () => { + it('backs up and restores under a new name', async () => { + const cfg = getSourceConfig('cockroachdb')!; + const c = commandsFor('cockroachdb'); + cleanups.push(() => tryCleanup('cockroachdb', [`DROP DATABASE IF EXISTS "${cfg.database}_restored" CASCADE`])); + const backup = await run('cockroachdb', statementsOf(c.backup)); + expect(backup.rejected).toBeUndefined(); + if (backup.skipped) return console.warn(backup.skipped); + const restore = await run('cockroachdb', statementsOf(c.restore)); + expect(restore.rejected).toBeUndefined(); + }, 180_000); +}); + +describe.skipIf(!hasConfig('clickhouse'))('ClickHouse backup', () => { + it('is accepted by the server, or refused only for want of a backup disk', async () => { + const c = commandsFor('clickhouse'); + const backup = await run('clickhouse', statementsOf(c.backup)); + expect(backup.rejected).toBeUndefined(); + if (backup.skipped) console.warn(backup.skipped); + }, 120_000); +}); + +describe.skipIf(!hasConfig('duckdb'))('DuckDB backup', () => { + it('exports the database to a folder', async () => { + const c = commandsFor('duckdb'); + // DuckDB runs inside the API server, on this machine in a local run. + cleanups.push(async () => fs.rmSync(c.location, { recursive: true, force: true })); + const backup = await run('duckdb', statementsOf(c.backup)); + expect(backup.rejected).toBeUndefined(); + if (backup.skipped) console.warn(backup.skipped); + }, 120_000); +}); diff --git a/apps/e2e/src/tests/backup-restore-ui.test.ts b/apps/e2e/src/tests/backup-restore-ui.test.ts new file mode 100644 index 000000000..63dad2bcf --- /dev/null +++ b/apps/e2e/src/tests/backup-restore-ui.test.ts @@ -0,0 +1,113 @@ +/** + * Backup & Restore in the Utilities workspace, in a real browser. + * + * The commands are unit-tested per engine; this checks the screen around + * them: it says where the command runs, writes it for the chosen connection + * without a password, keeps a saved folder as the user's default across a + * reload, and hands SQL Server's BACKUP DATABASE to the SQL editor. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { Page } from 'playwright'; +import { buildDriver, quitDriver, clickWhen, waitFor } from '../helpers/driver.js'; +import { getSourceConfig, hasConfig } from '../helpers/db-config.js'; +import { saveScreenshot } from '../helpers/screenshot.js'; +import { deleteSavedConnections } from '../helpers/sql-exec.js'; +import { byTestId } from '../helpers/test-ids.js'; +import { AppPage } from '../pages/AppPage.js'; +import { SqlEditorPage } from '../pages/SqlEditorPage.js'; + +const runId = Date.now().toString(36); +const names: Record = {}; +let driver: Page; +let sql: SqlEditorPage; + +async function openBackupFor(dialect: string): Promise { + await sql.openUtilitiesView(); + await clickWhen(driver, byTestId('utilities-backup')); + // Saved connections load after the page does; after a reload the list can + // still be empty when the workspace first paints. + await driver.waitForFunction( + (name) => + [...(document.querySelector('[data-testid="utilities-connection"]') as HTMLSelectElement | null)?.options ?? []].some((o) => + (o.textContent ?? '').includes(name) + ), + names[dialect]!, + { timeout: 15_000 } + ); + await sql.selectUtilityConnection(names[dialect]!, 'utilities-connection'); + await waitFor(driver, byTestId('backup-command-text'), 15_000); +} + +const commandText = () => driver.locator(byTestId('backup-command-text')).innerText(); + +describe.skipIf(!hasConfig('postgres'))('Backup & Restore', () => { + beforeAll(async () => { + driver = await buildDriver(); + sql = new SqlEditorPage(driver); + await new AppPage(driver).open(); + for (const dialect of ['postgres', 'sqlserver']) { + if (!hasConfig(dialect)) continue; + names[dialect] = `E2E Backup ${dialect} ${runId}`; + await sql.addCredential(names[dialect]!, getSourceConfig(dialect)!); + } + }, 300_000); + + afterAll(async () => { + await deleteSavedConnections(Object.values(names)); + if (driver) await quitDriver(driver); + }); + + it('says where pg_dump runs and writes it for this connection, with no password', async () => { + const cfg = getSourceConfig('postgres')!; + await openBackupFor('postgres'); + expect(await driver.locator(byTestId('backup-runs-on')).innerText()).toMatch(/Runs on your machine/); + const command = await commandText(); + expect(command).toMatch(/^pg_dump /); + expect(command).toContain(`--username ${cfg.username}`); + expect(command).not.toContain(cfg.password || '\u0000never'); + expect(await driver.locator(byTestId('restore-command-text')).innerText()).toMatch(/^pg_restore /); + await saveScreenshot(driver, 'backup-restore-postgres'); + }); + + it('keeps a saved folder as the default for the engine, across a reload', async () => { + // Unique per run: a run that died before resetting leaves its folder saved, + // and typing the saved value again leaves nothing to save. + const folder = `/nas/e2e-${runId}`; + await openBackupFor('postgres'); + await driver.locator(byTestId('backup-folder')).fill(folder); + await driver.locator(byTestId('backup-save-default')).click(); + await driver.waitForFunction( + () => /^Saved/.test(document.querySelector('[data-testid="backup-save-status"]')?.textContent ?? ''), + undefined, + { timeout: 10_000 } + ); + + await driver.reload(); + await openBackupFor('postgres'); + await driver.waitForFunction( + () => /saved default/.test(document.querySelector('[data-testid="backup-save-status"]')?.textContent ?? ''), + undefined, + { timeout: 10_000 } + ); + expect(await driver.locator(byTestId('backup-folder')).inputValue()).toBe(folder); + expect(await commandText()).toContain(`--file=${folder}/`); + + // Leave the e2e account's default as the engine's own. + await driver.locator(byTestId('backup-folder')).fill('./backups'); + await driver.locator(byTestId('backup-save-default')).click(); + await driver.waitForFunction( + () => /^Saved/.test(document.querySelector('[data-testid="backup-save-status"]')?.textContent ?? ''), + undefined, + { timeout: 10_000 } + ); + }); + + it.skipIf(!hasConfig('sqlserver'))('runs SQL Server’s backup on the server, and opens it in the SQL editor', async () => { + await openBackupFor('sqlserver'); + expect(await driver.locator(byTestId('backup-runs-on')).innerText()).toMatch(/database server/); + expect(await commandText()).toMatch(/^BACKUP DATABASE \[/); + await saveScreenshot(driver, 'backup-restore-sqlserver'); + await driver.locator(byTestId('backup-command-open-sql')).click(); + await waitFor(driver, byTestId('sql-editor-view'), 15_000); + }); +}); diff --git a/apps/web/src/frontend/features/utilities/components/BackupRestorePanel.test.tsx b/apps/web/src/frontend/features/utilities/components/BackupRestorePanel.test.tsx new file mode 100644 index 000000000..8b2e0bb69 --- /dev/null +++ b/apps/web/src/frontend/features/utilities/components/BackupRestorePanel.test.tsx @@ -0,0 +1,122 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { BackupRestorePanel } from './BackupRestorePanel'; + +const connections = [ + { id: 'pg', name: 'Shop', dialect: 'postgres', host: 'db.local', port: 5432, database: 'shop', schema: 'sales', username: 'fox' }, + { id: 'ms', name: 'Ledger', dialect: 'sqlserver', host: 'sql.local', port: 1433, database: 'ledger', username: 'sa_like' }, + { id: 'odd', name: 'Odd', dialect: 'notadb', database: 'x' }, +]; +vi.mock('@/app/store/useSyncStore', () => ({ + useSyncStore: (sel: (s: { connections: typeof connections }) => unknown) => sel({ connections }), +})); +const setSql = vi.fn(); +const ensureConnectionSelected = vi.fn(); +vi.mock('@/app/store/useSqlEditorStore', () => ({ + useSqlEditorStore: (sel: (s: { setSql: typeof setSql; ensureConnectionSelected: typeof ensureConnectionSelected }) => unknown) => + sel({ setSql, ensureConnectionSelected }), +})); +const setActiveView = vi.fn(); +vi.mock('@/app/store/uiStore', () => ({ + useUiStore: (sel: (s: { setActiveView: typeof setActiveView }) => unknown) => sel({ setActiveView }), +})); +const getSettings = vi.fn(); +const saveSettings = vi.fn(); +vi.mock('@/shared/api/backupApi', () => ({ + apiGetBackupSettings: () => getSettings(), + apiSaveBackupSettings: (dialect: string, s: unknown) => saveSettings(dialect, s), +})); +vi.mock('@/shared/utils/clipboard', () => ({ writeClipboard: vi.fn(() => Promise.resolve()) })); + +const backupText = () => screen.getByTestId('backup-command-text').textContent ?? ''; + +beforeEach(() => { + getSettings.mockResolvedValue({}); + saveSettings.mockImplementation((_d: string, s: unknown) => Promise.resolve(s)); +}); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('BackupRestorePanel', () => { + it('says where the command runs before anything else, and writes it for the engine', async () => { + render(); + await waitFor(() => expect(getSettings).toHaveBeenCalled()); + expect(screen.getByTestId('backup-runs-on').textContent).toMatch(/Runs on your machine/); + expect(backupText()).toMatch(/^pg_dump --host db\.local --port 5432 --username fox --format=custom --compress=9 --file=\.\/backups\/shop_\d{8}_\d{6}\.dump shop$/); + expect(screen.getByTestId('restore-command-text').textContent).toMatch(/^pg_restore /); + // A shell command cannot be run in the SQL editor. + expect(screen.queryByTestId('backup-command-open-sql')).toBeNull(); + }); + + it('rewrites the command as the reader changes folder, scope, tables and schema', async () => { + render(); + await waitFor(() => expect(getSettings).toHaveBeenCalled()); + fireEvent.change(screen.getByTestId('backup-folder'), { target: { value: '/srv/My Backups' } }); + fireEvent.click(screen.getByTestId('backup-scope-schema')); + fireEvent.click(screen.getByTestId('backup-limit-schema')); + expect(backupText()).toContain("--schema-only --compress=9 --schema=sales --file='/srv/My Backups/"); + fireEvent.change(screen.getByTestId('backup-tables'), { target: { value: 'orders\ncustomers' } }); + expect(backupText()).toContain('--table=sales.orders --table=sales.customers'); + expect(backupText()).not.toContain('--schema=sales'); + }); + + it('starts from the reader’s saved default for the engine, and saves a new one', async () => { + getSettings.mockResolvedValue({ postgres: { folder: '/nas/pg', format: 'plain', scope: 'full', compress: false, limitToSchema: false } }); + render(); + await waitFor(() => expect(backupText()).toContain('--file=/nas/pg/shop_')); + expect(screen.getByTestId('backup-save-status').textContent).toMatch(/saved default/); + expect((screen.getByTestId('backup-save-default') as HTMLButtonElement).disabled).toBe(true); + + fireEvent.click(screen.getByTestId('backup-format-custom')); + fireEvent.change(screen.getByTestId('backup-tables'), { target: { value: 'orders' } }); + fireEvent.click(screen.getByTestId('backup-save-default')); + await waitFor(() => expect(screen.getByTestId('backup-save-status').textContent).toMatch(/^Saved/)); + expect(saveSettings).toHaveBeenCalledWith('postgres', { folder: '/nas/pg', format: 'custom', scope: 'full', compress: false, limitToSchema: false }); + // Saving keeps the form as it was: the table filter is not a default and is not wiped. + expect((screen.getByTestId('backup-tables') as HTMLTextAreaElement).value).toBe('orders'); + }); + + it('offers SQL Server’s BACKUP DATABASE to the SQL editor, on this connection', async () => { + render(); + await waitFor(() => expect(getSettings).toHaveBeenCalled()); + expect(screen.getByTestId('backup-runs-on').textContent).toMatch(/database server/); + expect(screen.queryByTestId('backup-scope')).toBeNull(); + fireEvent.click(screen.getByTestId('backup-command-open-sql')); + expect(setSql).toHaveBeenCalledWith(expect.stringMatching(/^BACKUP DATABASE \[ledger\]/)); + expect(ensureConnectionSelected).toHaveBeenCalledWith('ms'); + expect(setActiveView).toHaveBeenCalledWith('sqlEditor'); + }); + + it('says so for an engine without backup commands, and without a connection', async () => { + render(); + expect(screen.getByTestId('backup-unsupported')).toBeTruthy(); + cleanup(); + render(); + expect(screen.getByTestId('backup-no-connection')).toBeTruthy(); + }); + + it('keeps what the reader typed when saved defaults arrive afterwards', async () => { + let arrive: (v: unknown) => void = () => undefined; + getSettings.mockReturnValue(new Promise((resolve) => (arrive = resolve))); + render(); + fireEvent.change(screen.getByTestId('backup-folder'), { target: { value: '/typed' } }); + arrive({ postgres: { folder: '/saved', format: 'plain', scope: 'full', compress: false, limitToSchema: false } }); + await waitFor(() => expect(screen.getByTestId('backup-save-status').textContent).toMatch(/saved default/)); + expect(backupText()).toContain('--file=/typed/'); + }); + + it('still works when saved defaults cannot be loaded', async () => { + getSettings.mockRejectedValue(new Error('offline')); + render(); + await waitFor(() => expect(screen.getByTestId('backup-save-status').textContent).toMatch(/could not be loaded/)); + expect(backupText()).toMatch(/^pg_dump /); + }); +}); diff --git a/apps/web/src/frontend/features/utilities/components/BackupRestorePanel.tsx b/apps/web/src/frontend/features/utilities/components/BackupRestorePanel.tsx new file mode 100644 index 000000000..9be268522 --- /dev/null +++ b/apps/web/src/frontend/features/utilities/components/BackupRestorePanel.tsx @@ -0,0 +1,394 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Backup & Restore: the commands that back this connection up, and bring it + * back. + * + * Fox Schema writes them and does not run them. The first thing on screen is + * *where* they run, because that decides what the folder means: pg_dump writes + * on the machine that runs it, SQL Server's BACKUP DATABASE on the database + * server, Oracle into a DIRECTORY object. A folder typed on the wrong side of + * that line is the most common way a backup "succeeds" into nowhere. + * + * Each engine's folder, format and scope can be saved as the user's default + * for that engine; they live server-side, per user, and never hold a password. + */ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { AlertTriangle, Check, Cloud, Copy, FileCode2, HardDrive, KeyRound, RotateCcw, Save, Server } from 'lucide-react'; +import { + backupFileName, + backupSupport, + buildBackupCommands, + defaultBackupSettings, + parseTableList, + type BackupRunsOn, + type BackupScope, + type BackupSettings, +} from '@foxschema/sql'; +import { useSyncStore } from '@/app/store/useSyncStore'; +import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; +import { useUiStore } from '@/app/store/uiStore'; +import { apiGetBackupSettings, apiSaveBackupSettings } from '@/shared/api/backupApi'; +import { writeClipboard } from '@/shared/utils/clipboard'; +import { dialectLabel } from '@/shared/lib/dialectLabel'; +import { EmptyState, Field, Segmented, inputCls } from '@/features/access/components/controls'; + +const RUNS_ON: Record }> = { + client: { + label: 'Runs on your machine', + body: 'Run it anywhere the tool is installed and can reach the database. The file is written where you run it.', + Icon: HardDrive, + }, + server: { + label: 'Runs on the database server', + body: 'The database writes the backup itself, so the folder is on the database server, not on this computer.', + Icon: Server, + }, + cloud: { + label: 'Kept by the cloud provider', + body: 'A snapshot the provider stores for you. There is no file to place.', + Icon: Cloud, + }, +}; + +/** + * Scope in plain words first, with the database term after. + * + * "Schema only" means a namespace to half the engines Fox Schema supports and + * "structure" to the other half; "DDL" means nothing to a reader who did not + * come up through databases. Saying what is in the file answers all of them. + */ +const SCOPE_LABEL: Record = { + full: { label: 'Everything', title: 'Tables, views and routines, and their rows.' }, + schema: { label: 'Structure only (DDL)', title: 'The CREATE statements for tables, views and routines. No rows.' }, + data: { label: 'Rows only (data)', title: 'The rows, without the statements that create the tables.' }, +}; + +const sameSettings = (a: BackupSettings, b: BackupSettings) => + a.folder === b.folder && a.format === b.format && a.scope === b.scope && a.compress === b.compress && a.limitToSchema === b.limitToSchema; + +const CommandBlock: React.FC<{ + title: string; + text: string; + testId: string; + canOpenInEditor: boolean; + onOpen: () => void; +}> = ({ title, text, testId, canOpenInEditor, onOpen }) => { + const [copied, setCopied] = useState(false); + return ( +
+
+

{title}

+
+ + {canOpenInEditor && ( + + )} +
+
+
+        {text}
+      
+
+ ); +}; + +export const BackupRestorePanel: React.FC<{ lockedConnectionId?: string }> = ({ lockedConnectionId }) => { + const connections = useSyncStore((s) => s.connections); + const setSql = useSqlEditorStore((s) => s.setSql); + const ensureConnectionSelected = useSqlEditorStore((s) => s.ensureConnectionSelected); + const setActiveView = useUiStore((s) => s.setActiveView); + + const conn = connections.find((c) => c.id === lockedConnectionId); + const dialect = (conn?.dialect ?? '').toLowerCase(); + const support = useMemo(() => (dialect ? backupSupport(dialect) : undefined), [dialect]); + + const [saved, setSaved] = useState | null>(null); + const [loadFailed, setLoadFailed] = useState(false); + const [settings, setSettings] = useState(() => defaultBackupSettings(dialect)); + const [fileName, setFileName] = useState(''); + const [tablesText, setTablesText] = useState(''); + const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | { error: string }>('idle'); + + useEffect(() => { + let live = true; + apiGetBackupSettings() + .then((s) => live && setSaved(s)) + .catch(() => live && (setSaved({}), setLoadFailed(true))); + return () => { + live = false; + }; + }, []); + + // A connection change starts from that engine's saved default, a fresh + // file name, and no table filter carried over from another database. + const savedForDialect = saved?.[dialect]; + const savedLoaded = saved !== null; + const edited = useRef(false); + useEffect(() => { + edited.current = false; + setSettings(saved?.[dialect] ?? defaultBackupSettings(dialect)); + setFileName(conn?.database ? backupFileName(conn.database, new Date()) : ''); + setTablesText(''); + setSaveState('idle'); + // eslint-disable-next-line react-hooks/exhaustive-deps -- `saved` arrives on its own below + }, [lockedConnectionId, dialect, conn?.database]); + // Saved defaults arrive after the first paint. They apply only if the reader + // has not started editing — and only on that first arrival: saving replaces + // them, and re-applying then would undo nothing but still look like a reset. + useEffect(() => { + if (savedLoaded && !edited.current) setSettings(saved?.[dialect] ?? defaultBackupSettings(dialect)); + // eslint-disable-next-line react-hooks/exhaustive-deps -- first arrival only, see above + }, [savedLoaded]); + + const commands = useMemo(() => { + if (!conn || !support) return null; + return buildBackupCommands( + { + dialect, + host: conn.host, + port: conn.port, + database: conn.database ?? '', + schema: conn.schema, + username: conn.username, + }, + { ...settings, fileName, tables: parseTableList(tablesText) } + ); + }, [conn, support, dialect, settings, fileName, tablesText]); + + const baseline = savedForDialect ?? defaultBackupSettings(dialect); + const changed = !sameSettings(settings, baseline); + const patch = (p: Partial) => { + edited.current = true; + setSettings((s) => ({ ...s, ...p })); + setSaveState('idle'); + }; + + const openInEditor = (sql: string) => { + if (!lockedConnectionId) return; + setSql?.(sql); + ensureConnectionSelected?.(lockedConnectionId); + setActiveView('sqlEditor'); + }; + + const save = async () => { + setSaveState('saving'); + try { + const stored = await apiSaveBackupSettings(dialect, settings); + setSaved((s) => ({ ...(s ?? {}), [dialect]: stored })); + setSaveState('saved'); + } catch (err) { + setSaveState({ error: err instanceof Error ? err.message : 'Could not save.' }); + } + }; + + if (!conn) { + return ( +
+ +
+ ); + } + if (!support) { + return ( +
+ +
+ ); + } + + const runsOn = RUNS_ON[support.runsOn]; + const format = support.formats.find((f) => f.id === settings.format) ?? support.formats[0]!; + const language = commands && !('error' in commands) ? commands.language : 'shell'; + + return ( +
+
+

+ {support.tool} + · Fox Schema writes these commands; it does not run them. +

+
+ + + {runsOn.label}. {runsOn.body} + +
+
+ +
+ {support.folder && ( + + patch({ folder: e.target.value })} + placeholder={support.folder.defaultValue} + className={inputCls} + /> + + )} + + setFileName(e.target.value)} + className={inputCls} + /> + + {support.formats.length > 1 && ( + + patch({ format: v })} + options={support.formats.map((f) => ({ value: f.id, label: f.label, title: f.hint }))} + /> + + )} + {support.scopes.length > 1 && ( + + patch({ scope: v as BackupScope })} + options={support.scopes.map((s) => ({ value: s, label: SCOPE_LABEL[s].label, title: SCOPE_LABEL[s].title }))} + /> + + )} + {support.tables && ( + +