From d72867500cd8ac73b00d7217348f599906eb35d9 Mon Sep 17 00:00:00 2001 From: Samir Mammadhasanov Date: Mon, 11 May 2026 11:16:04 +0400 Subject: [PATCH 01/11] =?UTF-8?q?feat(backup):=20add=20File=20>=20Backup?= =?UTF-8?q?=E2=80=A6=20for=20PostgreSQL=20via=20pg=5Fdump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks a database from the current connection, prompts for a destination, and runs pg_dump with the custom archive format (-Fc), surfacing live byte progress and a cancel that cleans up the partial file. Reuses the active SSH tunnel via session.effectiveConnection so tunneled connections work without spinning up a second tunnel. pg_dump path is auto-detected from PATH and common Homebrew locations, with an override under Settings > Terminal > CLI Paths. --- CHANGELOG.md | 1 + .../Core/Database/PostgresBackupService.swift | 207 ++++++++++++++++++ TablePro/Models/Settings/AppSettings.swift | 7 +- TablePro/TableProApp.swift | 5 + .../Views/Backup/BackupDatabaseFlow.swift | 151 +++++++++++++ .../Views/Backup/BackupProgressSheet.swift | 76 +++++++ TablePro/Views/Backup/BackupResultSheet.swift | 122 +++++++++++ .../DatabaseSwitcherSheet.swift | 69 ++++-- .../Main/MainContentCommandActions.swift | 9 + .../Views/Main/MainContentCoordinator.swift | 2 + TablePro/Views/Main/MainContentView.swift | 7 + .../Views/Settings/TerminalSettingsView.swift | 19 ++ 12 files changed, 661 insertions(+), 14 deletions(-) create mode 100644 TablePro/Core/Database/PostgresBackupService.swift create mode 100644 TablePro/Views/Backup/BackupDatabaseFlow.swift create mode 100644 TablePro/Views/Backup/BackupProgressSheet.swift create mode 100644 TablePro/Views/Backup/BackupResultSheet.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b4a4acc5d..d98e32dd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- File > Backup... for PostgreSQL and Redshift connections: pick a database from the current connection, choose a destination, and run `pg_dump` in custom archive format (`-Fc`) with progress and cancel. Reuses the existing SSH tunnel when one is active, and honors a custom pg_dump path under Settings > Terminal > CLI Paths. - Sidebar groups database objects into Tables, Views, Materialized Views, Foreign Tables, Procedures, and Functions sections; routines load automatically on connect for Postgres and MySQL, each section header has its own Refresh action, and "Show DDL" on a procedure or function opens its definition in a new query tab (#1038) - iOS: Live Activity for running queries shows query preview, elapsed time, and row count on the lock screen and Dynamic Island - iOS: multi-window support on iPad - drag a tab off to open a second window, each window remembers its own selected connection across launches diff --git a/TablePro/Core/Database/PostgresBackupService.swift b/TablePro/Core/Database/PostgresBackupService.swift new file mode 100644 index 000000000..d4300c6ee --- /dev/null +++ b/TablePro/Core/Database/PostgresBackupService.swift @@ -0,0 +1,207 @@ +// +// PostgresBackupService.swift +// TablePro +// + +import Foundation +import Observation +import os + +@MainActor +@Observable +final class PostgresBackupService { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "PostgresBackupService") + + enum State: Equatable { + case idle + case running(database: String, bytesWritten: Int64) + case cancelling + case finished(database: String, destination: URL, bytesWritten: Int64) + case failed(message: String) + case cancelled + } + + enum BackupError: LocalizedError { + case pgDumpNotFound + case unsupportedDatabase + case noSession + case alreadyRunning + + var errorDescription: String? { + switch self { + case .pgDumpNotFound: + return String(localized: """ + pg_dump was not found on this system. Install it with `brew install libpq` and \ + link it, or set a custom path under Settings > Terminal > CLI Paths > pg_dump. + """) + case .unsupportedDatabase: + return String(localized: "Backups are only supported for PostgreSQL and Redshift connections.") + case .noSession: + return String(localized: "Connect to the database before starting a backup.") + case .alreadyRunning: + return String(localized: "A backup is already running.") + } + } + } + + private(set) var state: State = .idle + + @ObservationIgnored private var process: Process? + @ObservationIgnored private var stderrBuffer = Data() + @ObservationIgnored private var byteSizeTask: Task? + + /// Returns the resolved pg_dump executable path, honoring the user-configured override. + nonisolated static func resolvePgDumpPath(customPath: String?) -> String? { + CLICommandResolver.findExecutable("pg_dump", customPath: customPath) + } + + private static func pgSSLMode(_ mode: SSLMode) -> String { + switch mode { + case .disabled: return "disable" + case .preferred: return "prefer" + case .required: return "require" + case .verifyCa: return "verify-ca" + case .verifyIdentity: return "verify-full" + } + } + + func start(connection: DatabaseConnection, database: String, destination: URL) async throws { + if case .running = state { throw BackupError.alreadyRunning } + if case .cancelling = state { throw BackupError.alreadyRunning } + + guard connection.type == .postgresql || connection.type == .redshift else { + throw BackupError.unsupportedDatabase + } + + let session = DatabaseManager.shared.session(for: connection.id) + guard session?.isConnected == true else { + throw BackupError.noSession + } + + let effective = session?.effectiveConnection ?? connection + + let customPath = AppSettingsManager.shared.terminal.cliPaths[TerminalSettings.pgDumpCliPathKey]?.nilIfEmpty + guard let pgDumpPath = Self.resolvePgDumpPath(customPath: customPath) else { + throw BackupError.pgDumpNotFound + } + + let password = ConnectionStorage.shared.loadPassword(for: connection.id) ?? session?.cachedPassword + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: pgDumpPath) + + var args: [String] = [ + "-Fc", + "--no-password", + "-h", effective.host.isEmpty ? "127.0.0.1" : effective.host, + "-p", String(effective.port), + "-d", database, + "-f", destination.path + ] + if !effective.username.isEmpty { + args.append(contentsOf: ["-U", effective.username]) + } + proc.arguments = args + + var env = ProcessInfo.processInfo.environment + if let password, !password.isEmpty { + env["PGPASSWORD"] = password + } + if effective.sslConfig.isEnabled { + env["PGSSLMODE"] = Self.pgSSLMode(effective.sslConfig.mode) + } + proc.environment = env + + let stderrPipe = Pipe() + proc.standardError = stderrPipe + proc.standardOutput = FileHandle.nullDevice + + stderrBuffer = Data() + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let chunk = handle.availableData + guard !chunk.isEmpty else { return } + Task { @MainActor [weak self] in + self?.stderrBuffer.append(chunk) + if self?.stderrBuffer.count ?? 0 > 64_000 { + let trimmed = self?.stderrBuffer.suffix(64_000) ?? Data() + self?.stderrBuffer = Data(trimmed) + } + } + } + + let dbName = database + proc.terminationHandler = { [weak self] terminated in + stderrPipe.fileHandleForReading.readabilityHandler = nil + Task { @MainActor [weak self] in + self?.handleTermination(terminated, database: dbName, destination: destination) + } + } + + do { + try proc.run() + } catch { + throw BackupError.pgDumpNotFound + } + + self.process = proc + state = .running(database: database, bytesWritten: 0) + startByteSizePolling(url: destination, database: database) + + Self.logger.info("pg_dump started pid=\(proc.processIdentifier, privacy: .public) db=\(dbName, privacy: .public)") + } + + func cancel() { + guard case .running = state, let proc = process else { return } + state = .cancelling + proc.terminate() + } + + private func handleTermination(_ proc: Process, database: String, destination: URL) { + byteSizeTask?.cancel() + byteSizeTask = nil + process = nil + + let exitCode = proc.terminationStatus + let bytes = (try? FileManager.default.attributesOfItem(atPath: destination.path)[.size] as? Int64) ?? 0 + + if exitCode == 0 { + state = .finished(database: database, destination: destination, bytesWritten: bytes) + Self.logger.info("pg_dump finished bytes=\(bytes) db=\(database, privacy: .public)") + return + } + + if case .cancelling = state { + try? FileManager.default.removeItem(at: destination) + state = .cancelled + Self.logger.notice("pg_dump cancelled db=\(database, privacy: .public)") + return + } + + try? FileManager.default.removeItem(at: destination) + let stderrText = String(data: stderrBuffer, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let summary = stderrText.isEmpty + ? String(format: String(localized: "pg_dump exited with code %d"), Int(exitCode)) + : stderrText + state = .failed(message: summary) + Self.logger.error("pg_dump failed code=\(exitCode) db=\(database, privacy: .public) stderr=\(stderrText, privacy: .public)") + } + + private func startByteSizePolling(url: URL, database: String) { + byteSizeTask?.cancel() + byteSizeTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 250_000_000) + guard let self else { return } + guard case .running = self.state else { return } + let size = (try? FileManager.default + .attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0 + self.state = .running(database: database, bytesWritten: size) + } + } + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index b4b69584d..6a021ec69 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -347,9 +347,14 @@ struct TerminalSettings: Codable, Equatable { var bellEnabled: Bool = true var themeName: String = "" - /// Per-database CLI path overrides (empty = auto-detect) + /// Per-database CLI path overrides (empty = auto-detect). + /// Keys are `DatabaseType.rawValue` for interactive CLIs, plus + /// `TerminalSettings.pgDumpCliPathKey` for the pg_dump backup binary. var cliPaths: [String: String] = [:] + /// Key under `cliPaths` for the pg_dump backup binary path. + static let pgDumpCliPathKey = "pg_dump" + static let `default` = TerminalSettings() init( diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index c7cd4077a..78fe0f2de 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -299,6 +299,11 @@ struct AppMenuCommands: Commands { || actions?.isReadOnly ?? false || !(actions.map { PluginManager.shared.supportsImport(for: $0.currentDatabaseType) } ?? true) ) + + Button(String(localized: "Backup...")) { + actions?.backupDatabase() + } + .disabled(!(actions?.isConnected ?? false) || !(actions?.supportsBackup ?? false)) } // Query menu diff --git a/TablePro/Views/Backup/BackupDatabaseFlow.swift b/TablePro/Views/Backup/BackupDatabaseFlow.swift new file mode 100644 index 000000000..9c9cf0d70 --- /dev/null +++ b/TablePro/Views/Backup/BackupDatabaseFlow.swift @@ -0,0 +1,151 @@ +// +// BackupDatabaseFlow.swift +// TablePro +// +// Top-level sheet for the Backup Database menu item. Reuses +// `DatabaseSwitcherSheet` in `.backup` mode to pick the database, +// then drives a NSSavePanel and the `PostgresBackupService` progress flow. +// + +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +struct BackupDatabaseFlow: View { + @Binding var isPresented: Bool + let connection: DatabaseConnection + let initialDatabase: String + + @State private var service = PostgresBackupService() + @State private var phase: Phase + + private enum Phase: Equatable { + case pickDatabase + case running(database: String) + case finished(database: String, destination: URL, bytes: Int64) + case failed(message: String) + case cancelled + } + + init(isPresented: Binding, connection: DatabaseConnection, initialDatabase: String) { + self._isPresented = isPresented + self.connection = connection + self.initialDatabase = initialDatabase + self._phase = State(initialValue: .pickDatabase) + } + + var body: some View { + Group { + switch phase { + case .pickDatabase: + pickerView + case .running(let database): + BackupProgressSheet( + database: database, + bytesWritten: bytesWritten, + isCancelling: service.state == .cancelling, + onCancel: { service.cancel() } + ) + case .finished(let database, let destination, let bytes): + BackupResultSheet( + outcome: .success(database: database, destination: destination, bytes: bytes), + onClose: { isPresented = false }, + onShowInFinder: { NSWorkspace.shared.activateFileViewerSelecting([destination]) } + ) + case .failed(let message): + BackupResultSheet( + outcome: .failure(message: message), + onClose: { isPresented = false }, + onShowInFinder: nil + ) + case .cancelled: + BackupResultSheet( + outcome: .cancelled, + onClose: { isPresented = false }, + onShowInFinder: nil + ) + } + } + .onChange(of: serviceState) { _, newState in + handleServiceStateChange(newState) + } + } + + private var pickerView: some View { + DatabaseSwitcherSheet( + isPresented: $isPresented, + mode: .backup, + currentDatabase: initialDatabase, + databaseType: connection.type, + connectionId: connection.id, + onSelect: { database in + Task { await promptForDestination(database: database) } + } + ) + } + + private var bytesWritten: Int64 { + if case .running(_, let bytes) = service.state { return bytes } + return 0 + } + + /// Hashable snapshot of `service.state` so SwiftUI's `onChange` fires on every transition. + private var serviceState: PostgresBackupService.State { service.state } + + private func handleServiceStateChange(_ state: PostgresBackupService.State) { + switch state { + case .running(let database, _): + phase = .running(database: database) + case .finished(let database, let destination, let bytes): + phase = .finished(database: database, destination: destination, bytes: bytes) + case .failed(let message): + phase = .failed(message: message) + case .cancelled: + phase = .cancelled + case .idle, .cancelling: + break + } + } + + private func promptForDestination(database: String) async { + let savePanel = NSSavePanel() + savePanel.canCreateDirectories = true + savePanel.showsTagField = false + savePanel.allowedContentTypes = [UTType(filenameExtension: "dump") ?? .data] + savePanel.nameFieldStringValue = Self.defaultFilename(database: database) + savePanel.title = String(localized: "Save Backup") + savePanel.message = String(format: String(localized: "Choose where to save the backup of \u{201C}%@\u{201D}."), database) + + let window = NSApp.keyWindow + let response: NSApplication.ModalResponse + if let window { + response = await savePanel.beginSheetModal(for: window) + } else { + response = savePanel.runModal() + } + + guard response == .OK, let url = savePanel.url else { + phase = .pickDatabase + return + } + + do { + try await service.start(connection: connection, database: database, destination: url) + } catch { + phase = .failed(message: error.localizedDescription) + } + } + + private static func defaultFilename(database: String) -> String { + let timestamp = Self.timestampFormatter.string(from: Date()) + let safeDB = database.isEmpty ? "database" : database + return "\(safeDB)-\(timestamp).dump" + } + + private static let timestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd-HHmmss" + formatter.locale = Locale(identifier: "en_US_POSIX") + return formatter + }() +} diff --git a/TablePro/Views/Backup/BackupProgressSheet.swift b/TablePro/Views/Backup/BackupProgressSheet.swift new file mode 100644 index 000000000..e918d44eb --- /dev/null +++ b/TablePro/Views/Backup/BackupProgressSheet.swift @@ -0,0 +1,76 @@ +// +// BackupProgressSheet.swift +// TablePro +// + +import SwiftUI + +struct BackupProgressSheet: View { + let database: String + let bytesWritten: Int64 + let isCancelling: Bool + let onCancel: () -> Void + + @State private var showCancelConfirmation = false + + var body: some View { + VStack(spacing: 20) { + Text("Backing Up Database") + .font(.title3.weight(.semibold)) + + VStack(spacing: 8) { + HStack { + Text(database) + .font(.body) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(byteCountString) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + + ProgressView() + .progressViewStyle(.linear) + } + + HStack(spacing: 8) { + if isCancelling { + ProgressView().controlSize(.small) + Text("Cancelling\u{2026}") + .font(.callout) + .foregroundStyle(.secondary) + } else { + Button("Cancel") { + showCancelConfirmation = true + } + .frame(width: 100) + } + } + } + .padding(24) + .frame(width: 420) + .background(Color(nsColor: .windowBackgroundColor)) + .interactiveDismissDisabled() + .alert(String(localized: "Cancel Backup?"), isPresented: $showCancelConfirmation) { + Button(String(localized: "Continue"), role: .cancel) { } + Button(String(localized: "Cancel Backup"), role: .destructive) { onCancel() } + } message: { + Text("The partial backup file will be removed.") + } + } + + private var byteCountString: String { + ByteCountFormatter.string(fromByteCount: bytesWritten, countStyle: .file) + } +} + +#Preview { + BackupProgressSheet( + database: "production", + bytesWritten: 12_345_678, + isCancelling: false, + onCancel: {} + ) +} diff --git a/TablePro/Views/Backup/BackupResultSheet.swift b/TablePro/Views/Backup/BackupResultSheet.swift new file mode 100644 index 000000000..f0045ab9d --- /dev/null +++ b/TablePro/Views/Backup/BackupResultSheet.swift @@ -0,0 +1,122 @@ +// +// BackupResultSheet.swift +// TablePro +// + +import SwiftUI + +struct BackupResultSheet: View { + enum Outcome { + case success(database: String, destination: URL, bytes: Int64) + case failure(message: String) + case cancelled + } + + let outcome: Outcome + let onClose: () -> Void + let onShowInFinder: (() -> Void)? + + var body: some View { + VStack(spacing: 16) { + icon + .font(.system(size: 36)) + .foregroundStyle(tintColor) + + Text(title) + .font(.title3.weight(.semibold)) + .multilineTextAlignment(.center) + + if let detail { + Text(detail) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineLimit(8) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + + HStack(spacing: 12) { + if case .success = outcome, let onShowInFinder { + Button(String(localized: "Show in Finder")) { + onShowInFinder() + onClose() + } + } + Button(String(localized: "Done")) { + onClose() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return) + } + } + .padding(24) + .frame(width: 420) + .background(Color(nsColor: .windowBackgroundColor)) + } + + @ViewBuilder + private var icon: some View { + switch outcome { + case .success: + Image(systemName: "checkmark.circle.fill") + case .failure: + Image(systemName: "exclamationmark.triangle.fill") + case .cancelled: + Image(systemName: "xmark.circle.fill") + } + } + + private var tintColor: Color { + switch outcome { + case .success: return Color(nsColor: .systemGreen) + case .failure: return Color(nsColor: .systemOrange) + case .cancelled: return Color(nsColor: .systemGray) + } + } + + private var title: String { + switch outcome { + case .success: return String(localized: "Backup Complete") + case .failure: return String(localized: "Backup Failed") + case .cancelled: return String(localized: "Backup Cancelled") + } + } + + private var detail: String? { + switch outcome { + case .success(let database, let destination, let bytes): + let size = ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + return String( + format: String(localized: "Saved %@ of \u{201C}%@\u{201D} to %@"), + size, + database, + destination.path + ) + case .failure(let message): + return message + case .cancelled: + return nil + } + } +} + +#Preview("Success") { + BackupResultSheet( + outcome: .success( + database: "production", + destination: URL(fileURLWithPath: "/Users/me/Desktop/production-2025-05-11-120000.dump"), + bytes: 12_345_678 + ), + onClose: {}, + onShowInFinder: {} + ) +} + +#Preview("Failure") { + BackupResultSheet( + outcome: .failure(message: "pg_dump: error: connection to server failed: FATAL: password authentication failed for user \"postgres\""), + onClose: {}, + onShowInFinder: nil + ) +} diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 6b9b807b1..e64d5403a 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -11,9 +11,17 @@ import SwiftUI import TableProPluginKit struct DatabaseSwitcherSheet: View { + /// What the sheet is being used for. `switch` (default) switches the active + /// database/schema; `backup` picks a database to feed into a backup flow. + enum Mode { + case `switch` + case backup + } + @Binding var isPresented: Bool @Environment(\.dismiss) private var dismiss + let mode: Mode let currentDatabase: String? let currentSchema: String? let databaseType: DatabaseType @@ -41,12 +49,17 @@ struct DatabaseSwitcherSheet: View { } init( - isPresented: Binding, currentDatabase: String?, currentSchema: String? = nil, + isPresented: Binding, + mode: Mode = .switch, + currentDatabase: String?, + currentSchema: String? = nil, databaseType: DatabaseType, - connectionId: UUID, onSelect: @escaping (String) -> Void, + connectionId: UUID, + onSelect: @escaping (String) -> Void, onSelectSchema: ((String) -> Void)? = nil ) { self._isPresented = isPresented + self.mode = mode self.currentDatabase = currentDatabase self.currentSchema = currentSchema self.databaseType = databaseType @@ -64,8 +77,8 @@ struct DatabaseSwitcherSheet: View { var body: some View { VStack(spacing: 0) { - // Databases / Schemas toggle (PostgreSQL only) - if PluginManager.shared.supportsSchemaSwitching(for: databaseType) { + // Databases / Schemas toggle (PostgreSQL only); always Databases in backup mode. + if mode == .switch, PluginManager.shared.supportsSchemaSwitching(for: databaseType) { Picker("", selection: $viewModel.mode) { Text(String(localized: "Databases")) .tag(DatabaseSwitcherViewModel.Mode.database) @@ -108,9 +121,7 @@ struct DatabaseSwitcherSheet: View { footer } .frame(width: 420, height: 480) - .navigationTitle(isSchemaMode - ? String(localized: "Open Schema") - : String(localized: "Open Database")) + .navigationTitle(navigationTitleString) .background(Color(nsColor: .windowBackgroundColor)) .task { await viewModel.fetchDatabases() } .task { await refreshCreateSupport() } @@ -173,7 +184,7 @@ struct DatabaseSwitcherSheet: View { .buttonStyle(.borderless) .help(String(localized: "Refresh database list")) - if !isSchemaMode && supportsCreateDatabase { + if mode == .switch, !isSchemaMode, supportsCreateDatabase { Button(action: { showCreateDialog = true }) { Image(systemName: "plus") .frame(width: 24, height: 24) @@ -183,7 +194,7 @@ struct DatabaseSwitcherSheet: View { } // Drop - if !isSchemaMode && PluginManager.shared.supportsDropDatabase(for: databaseType) { + if mode == .switch, !isSchemaMode, PluginManager.shared.supportsDropDatabase(for: databaseType) { Button(action: { initiateDropForSelected() }) { Image(systemName: "trash") .frame(width: 24, height: 24) @@ -360,6 +371,32 @@ struct DatabaseSwitcherSheet: View { // MARK: - Footer + private var navigationTitleString: String { + switch mode { + case .switch: + return isSchemaMode + ? String(localized: "Open Schema") + : String(localized: "Open Database") + case .backup: + return String(localized: "Back Up Database") + } + } + + private var primaryButtonLabel: String { + switch mode { + case .switch: return String(localized: "Open") + case .backup: return String(localized: "Back Up\u{2026}") + } + } + + private var primaryButtonDisabled: Bool { + guard let selected = viewModel.selectedDatabase else { return true } + // In switch mode, picking the already-active database/schema is a no-op. + // In backup mode, the active database is a perfectly fine target. + if mode == .switch, selected == activeName { return true } + return false + } + private var footer: some View { HStack { Button("Cancel") { @@ -368,13 +405,11 @@ struct DatabaseSwitcherSheet: View { Spacer() - Button("Open") { + Button(primaryButtonLabel) { openSelectedDatabase() } .buttonStyle(.borderedProminent) - .disabled( - viewModel.selectedDatabase == nil || viewModel.selectedDatabase == activeName - ) + .disabled(primaryButtonDisabled) .keyboardShortcut(.return, modifiers: []) } .padding(12) @@ -412,6 +447,14 @@ struct DatabaseSwitcherSheet: View { private func openSelectedDatabase() { guard let database = viewModel.selectedDatabase else { return } + // Backup mode: hand the selection off to the parent flow without + // dismissing. The host sheet stays mounted and transitions to the + // next step (save panel, then progress). + if mode == .backup { + onSelect(database) + return + } + // Don't reopen current database/schema if database == activeName { dismiss() diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 29324a8dd..44314db5b 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -688,6 +688,15 @@ final class MainContentCommandActions { coordinator?.openImportDialog() } + func backupDatabase() { + coordinator?.activeSheet = .backupDatabase + } + + /// Backups currently ship for PostgreSQL and Redshift (both use pg_dump). + var supportsBackup: Bool { + connection.type == .postgresql || connection.type == .redshift + } + func saveAsFavorite() { coordinator?.saveCurrentQueryAsFavorite() } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index fb8668322..62d781411 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -46,6 +46,7 @@ enum ActiveSheet: Identifiable { case exportDialog case importDialog case exportQueryResults + case backupDatabase case maintenance(operation: String, tableName: String) var id: String { @@ -54,6 +55,7 @@ enum ActiveSheet: Identifiable { case .exportDialog: "exportDialog" case .importDialog: "importDialog" case .exportQueryResults: "exportQueryResults" + case .backupDatabase: "backupDatabase" case .maintenance(let operation, let tableName): "maintenance-\(operation)-\(tableName)" } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 953f5e35d..1e9a5f4af 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -198,6 +198,13 @@ struct MainContentView: View { connection: connection, initialFileURL: coordinator.importFileURL ) + case .backupDatabase: + BackupDatabaseFlow( + isPresented: dismissBinding, + connection: connectionWithCurrentDatabase, + initialDatabase: DatabaseManager.shared.session(for: connection.id)?.currentDatabase + ?? connection.database + ) case .maintenance(let operation, let tableName): MaintenanceSheet( operation: operation, diff --git a/TablePro/Views/Settings/TerminalSettingsView.swift b/TablePro/Views/Settings/TerminalSettingsView.swift index 412f9cc9f..50df78add 100644 --- a/TablePro/Views/Settings/TerminalSettingsView.swift +++ b/TablePro/Views/Settings/TerminalSettingsView.swift @@ -126,6 +126,7 @@ struct TerminalSettingsView: View { ForEach(Self.terminalDatabaseTypes, id: \.rawValue) { dbType in cliPathRow(for: dbType) } + pgDumpPathRow } } footer: { Text("Override auto-detected CLI paths per database type.") @@ -147,8 +148,20 @@ struct TerminalSettingsView: View { TextField(dbType.displayName, text: binding, prompt: Text(resolved)) } + @ViewBuilder + private var pgDumpPathRow: some View { + let key = TerminalSettings.pgDumpCliPathKey + let binding = Binding( + get: { settings.cliPaths[key] ?? "" }, + set: { settings.cliPaths[key] = $0.isEmpty ? nil : $0 } + ) + let resolved = resolvedPaths[key] ?? "pg_dump" + TextField("pg_dump", text: binding, prompt: Text(resolved)) + } + private func resolveAllCliPaths() async { let dbTypes = Self.terminalDatabaseTypes + let pgDumpKey = TerminalSettings.pgDumpCliPathKey let results = await withTaskGroup(of: (String, String).self) { group in for dbType in dbTypes { group.addTask { @@ -159,6 +172,12 @@ struct TerminalSettingsView: View { return (dbType.rawValue, resolved ?? name) } } + group.addTask { + let resolved = await Task.detached(priority: .utility) { + CLICommandResolver.findExecutable("pg_dump") + }.value + return (pgDumpKey, resolved ?? "pg_dump") + } var paths: [String: String] = [:] for await (key, value) in group { paths[key] = value From 7155fb2e35a7227c001c882ae175e674b8ff0c63 Mon Sep 17 00:00:00 2001 From: Samir Mammadhasanov Date: Mon, 11 May 2026 11:29:40 +0400 Subject: [PATCH 02/11] =?UTF-8?q?feat(backup):=20add=20File=20>=20Restore?= =?UTF-8?q?=E2=80=A6=20counterpart=20for=20pg=5Frestore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks a .dump file via NSOpenPanel, then a target database on the current PostgreSQL or Redshift connection, then runs pg_restore --no-owner --no-acl with progress and cancel. Reuses the active SSH tunnel via session.effectiveConnection. pg_restore path is auto-detected with a custom override under Settings > Terminal > CLI Paths. The backup progress and result sheets gain a Kind enum so both flows share them. DatabaseSwitcherSheet gains a .restore mode alongside .backup; both hide the schemas tab and create/drop affordances and hand the selection to the parent flow without auto-dismissing. --- CHANGELOG.md | 1 + .../Database/PostgresRestoreService.swift | 195 ++++++++++++++++++ TablePro/Models/Settings/AppSettings.swift | 6 +- TablePro/TableProApp.swift | 9 + .../Views/Backup/BackupDatabaseFlow.swift | 6 +- .../Views/Backup/BackupProgressSheet.swift | 68 +++++- TablePro/Views/Backup/BackupResultSheet.swift | 70 +++++-- .../Views/Backup/RestoreDatabaseFlow.swift | 134 ++++++++++++ .../DatabaseSwitcherSheet.swift | 30 ++- .../Main/MainContentCommandActions.swift | 26 +++ .../Views/Main/MainContentCoordinator.swift | 2 + TablePro/Views/Main/MainContentView.swift | 8 + .../Views/Settings/TerminalSettingsView.swift | 3 +- 13 files changed, 524 insertions(+), 34 deletions(-) create mode 100644 TablePro/Core/Database/PostgresRestoreService.swift create mode 100644 TablePro/Views/Backup/RestoreDatabaseFlow.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d98e32dd8..d9b744c47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - File > Backup... for PostgreSQL and Redshift connections: pick a database from the current connection, choose a destination, and run `pg_dump` in custom archive format (`-Fc`) with progress and cancel. Reuses the existing SSH tunnel when one is active, and honors a custom pg_dump path under Settings > Terminal > CLI Paths. +- File > Restore... for PostgreSQL and Redshift connections: pick a `pg_dump` backup file, pick a target database on the current connection, and run `pg_restore --no-owner --no-acl` with progress and cancel. Reuses the existing SSH tunnel, and honors a custom pg_restore path under Settings > Terminal > CLI Paths. - Sidebar groups database objects into Tables, Views, Materialized Views, Foreign Tables, Procedures, and Functions sections; routines load automatically on connect for Postgres and MySQL, each section header has its own Refresh action, and "Show DDL" on a procedure or function opens its definition in a new query tab (#1038) - iOS: Live Activity for running queries shows query preview, elapsed time, and row count on the lock screen and Dynamic Island - iOS: multi-window support on iPad - drag a tab off to open a second window, each window remembers its own selected connection across launches diff --git a/TablePro/Core/Database/PostgresRestoreService.swift b/TablePro/Core/Database/PostgresRestoreService.swift new file mode 100644 index 000000000..5bee46adf --- /dev/null +++ b/TablePro/Core/Database/PostgresRestoreService.swift @@ -0,0 +1,195 @@ +// +// PostgresRestoreService.swift +// TablePro +// + +import Foundation +import Observation +import os + +@MainActor +@Observable +final class PostgresRestoreService { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "PostgresRestoreService") + + enum State: Equatable { + case idle + case running(database: String, source: URL) + case cancelling + case finished(database: String, source: URL) + case failed(message: String) + case cancelled + } + + enum RestoreError: LocalizedError { + case pgRestoreNotFound + case unsupportedDatabase + case noSession + case alreadyRunning + case sourceUnreadable + + var errorDescription: String? { + switch self { + case .pgRestoreNotFound: + return String(localized: """ + pg_restore was not found on this system. Install it with `brew install libpq` and \ + link it, or set a custom path under Settings > Terminal > CLI Paths > pg_restore. + """) + case .unsupportedDatabase: + return String(localized: "Restore is only supported for PostgreSQL and Redshift connections.") + case .noSession: + return String(localized: "Connect to the database before starting a restore.") + case .alreadyRunning: + return String(localized: "A restore is already running.") + case .sourceUnreadable: + return String(localized: "The selected backup file is not readable.") + } + } + } + + private(set) var state: State = .idle + + @ObservationIgnored private var process: Process? + @ObservationIgnored private var stderrBuffer = Data() + + /// Returns the resolved pg_restore executable path, honoring the user-configured override. + nonisolated static func resolvePgRestorePath(customPath: String?) -> String? { + CLICommandResolver.findExecutable("pg_restore", customPath: customPath) + } + + private static func pgSSLMode(_ mode: SSLMode) -> String { + switch mode { + case .disabled: return "disable" + case .preferred: return "prefer" + case .required: return "require" + case .verifyCa: return "verify-ca" + case .verifyIdentity: return "verify-full" + } + } + + func start(connection: DatabaseConnection, database: String, source: URL) async throws { + if case .running = state { throw RestoreError.alreadyRunning } + if case .cancelling = state { throw RestoreError.alreadyRunning } + + guard connection.type == .postgresql || connection.type == .redshift else { + throw RestoreError.unsupportedDatabase + } + guard FileManager.default.isReadableFile(atPath: source.path) else { + throw RestoreError.sourceUnreadable + } + + let session = DatabaseManager.shared.session(for: connection.id) + guard session?.isConnected == true else { + throw RestoreError.noSession + } + + let effective = session?.effectiveConnection ?? connection + + let customPath = AppSettingsManager.shared.terminal + .cliPaths[TerminalSettings.pgRestoreCliPathKey]?.nilIfEmpty + guard let pgRestorePath = Self.resolvePgRestorePath(customPath: customPath) else { + throw RestoreError.pgRestoreNotFound + } + + let password = ConnectionStorage.shared.loadPassword(for: connection.id) ?? session?.cachedPassword + + let proc = Process() + proc.executableURL = URL(fileURLWithPath: pgRestorePath) + + var args: [String] = [ + "--no-password", + "--no-owner", + "--no-acl", + "-h", effective.host.isEmpty ? "127.0.0.1" : effective.host, + "-p", String(effective.port), + "-d", database + ] + if !effective.username.isEmpty { + args.append(contentsOf: ["-U", effective.username]) + } + args.append(source.path) + proc.arguments = args + + var env = ProcessInfo.processInfo.environment + if let password, !password.isEmpty { + env["PGPASSWORD"] = password + } + if effective.sslConfig.isEnabled { + env["PGSSLMODE"] = Self.pgSSLMode(effective.sslConfig.mode) + } + proc.environment = env + + let stderrPipe = Pipe() + proc.standardError = stderrPipe + proc.standardOutput = FileHandle.nullDevice + + stderrBuffer = Data() + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let chunk = handle.availableData + guard !chunk.isEmpty else { return } + Task { @MainActor [weak self] in + self?.stderrBuffer.append(chunk) + if self?.stderrBuffer.count ?? 0 > 64_000 { + let trimmed = self?.stderrBuffer.suffix(64_000) ?? Data() + self?.stderrBuffer = Data(trimmed) + } + } + } + + let dbName = database + let sourceURL = source + proc.terminationHandler = { [weak self] terminated in + stderrPipe.fileHandleForReading.readabilityHandler = nil + Task { @MainActor [weak self] in + self?.handleTermination(terminated, database: dbName, source: sourceURL) + } + } + + do { + try proc.run() + } catch { + throw RestoreError.pgRestoreNotFound + } + + self.process = proc + state = .running(database: database, source: source) + + Self.logger.info("pg_restore started pid=\(proc.processIdentifier, privacy: .public) db=\(dbName, privacy: .public)") + } + + func cancel() { + guard case .running = state, let proc = process else { return } + state = .cancelling + proc.terminate() + } + + private func handleTermination(_ proc: Process, database: String, source: URL) { + process = nil + + let exitCode = proc.terminationStatus + + if case .cancelling = state { + state = .cancelled + Self.logger.notice("pg_restore cancelled db=\(database, privacy: .public)") + return + } + + if exitCode == 0 { + state = .finished(database: database, source: source) + Self.logger.info("pg_restore finished db=\(database, privacy: .public)") + return + } + + let stderrText = String(data: stderrBuffer, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let summary = stderrText.isEmpty + ? String(format: String(localized: "pg_restore exited with code %d"), Int(exitCode)) + : stderrText + state = .failed(message: summary) + Self.logger.error("pg_restore failed code=\(exitCode) db=\(database, privacy: .public) stderr=\(stderrText, privacy: .public)") + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index 6a021ec69..188fe0d59 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -349,12 +349,16 @@ struct TerminalSettings: Codable, Equatable { /// Per-database CLI path overrides (empty = auto-detect). /// Keys are `DatabaseType.rawValue` for interactive CLIs, plus - /// `TerminalSettings.pgDumpCliPathKey` for the pg_dump backup binary. + /// `TerminalSettings.pgDumpCliPathKey` and `TerminalSettings.pgRestoreCliPathKey` + /// for the PostgreSQL backup/restore binaries. var cliPaths: [String: String] = [:] /// Key under `cliPaths` for the pg_dump backup binary path. static let pgDumpCliPathKey = "pg_dump" + /// Key under `cliPaths` for the pg_restore binary path. + static let pgRestoreCliPathKey = "pg_restore" + static let `default` = TerminalSettings() init( diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 78fe0f2de..cdf0d825c 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -304,6 +304,15 @@ struct AppMenuCommands: Commands { actions?.backupDatabase() } .disabled(!(actions?.isConnected ?? false) || !(actions?.supportsBackup ?? false)) + + Button(String(localized: "Restore...")) { + actions?.restoreDatabase() + } + .disabled( + !(actions?.isConnected ?? false) + || !(actions?.supportsRestore ?? false) + || actions?.isReadOnly ?? false + ) } // Query menu diff --git a/TablePro/Views/Backup/BackupDatabaseFlow.swift b/TablePro/Views/Backup/BackupDatabaseFlow.swift index 9c9cf0d70..d0b4350e3 100644 --- a/TablePro/Views/Backup/BackupDatabaseFlow.swift +++ b/TablePro/Views/Backup/BackupDatabaseFlow.swift @@ -41,6 +41,7 @@ struct BackupDatabaseFlow: View { pickerView case .running(let database): BackupProgressSheet( + kind: .backup, database: database, bytesWritten: bytesWritten, isCancelling: service.state == .cancelling, @@ -48,18 +49,21 @@ struct BackupDatabaseFlow: View { ) case .finished(let database, let destination, let bytes): BackupResultSheet( - outcome: .success(database: database, destination: destination, bytes: bytes), + kind: .backup, + outcome: .backupSuccess(database: database, destination: destination, bytes: bytes), onClose: { isPresented = false }, onShowInFinder: { NSWorkspace.shared.activateFileViewerSelecting([destination]) } ) case .failed(let message): BackupResultSheet( + kind: .backup, outcome: .failure(message: message), onClose: { isPresented = false }, onShowInFinder: nil ) case .cancelled: BackupResultSheet( + kind: .backup, outcome: .cancelled, onClose: { isPresented = false }, onShowInFinder: nil diff --git a/TablePro/Views/Backup/BackupProgressSheet.swift b/TablePro/Views/Backup/BackupProgressSheet.swift index e918d44eb..2875aca29 100644 --- a/TablePro/Views/Backup/BackupProgressSheet.swift +++ b/TablePro/Views/Backup/BackupProgressSheet.swift @@ -2,11 +2,20 @@ // BackupProgressSheet.swift // TablePro // +// Shared progress sheet for the backup and restore flows. +// import SwiftUI struct BackupProgressSheet: View { + enum Kind { + case backup + case restore + } + + let kind: Kind let database: String + /// Number of bytes written so far. Only shown for `.backup`; ignored for `.restore`. let bytesWritten: Int64 let isCancelling: Bool let onCancel: () -> Void @@ -15,7 +24,7 @@ struct BackupProgressSheet: View { var body: some View { VStack(spacing: 20) { - Text("Backing Up Database") + Text(titleString) .font(.title3.weight(.semibold)) VStack(spacing: 8) { @@ -25,10 +34,12 @@ struct BackupProgressSheet: View { .lineLimit(1) .truncationMode(.middle) Spacer() - Text(byteCountString) - .font(.system(.body, design: .monospaced)) - .foregroundStyle(.secondary) - .monospacedDigit() + if kind == .backup { + Text(byteCountString) + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) + .monospacedDigit() + } } ProgressView() @@ -53,11 +64,39 @@ struct BackupProgressSheet: View { .frame(width: 420) .background(Color(nsColor: .windowBackgroundColor)) .interactiveDismissDisabled() - .alert(String(localized: "Cancel Backup?"), isPresented: $showCancelConfirmation) { + .alert(cancelAlertTitle, isPresented: $showCancelConfirmation) { Button(String(localized: "Continue"), role: .cancel) { } - Button(String(localized: "Cancel Backup"), role: .destructive) { onCancel() } + Button(cancelAlertConfirmLabel, role: .destructive) { onCancel() } } message: { - Text("The partial backup file will be removed.") + Text(cancelAlertMessage) + } + } + + private var titleString: String { + switch kind { + case .backup: return String(localized: "Backing Up Database") + case .restore: return String(localized: "Restoring Database") + } + } + + private var cancelAlertTitle: String { + switch kind { + case .backup: return String(localized: "Cancel Backup?") + case .restore: return String(localized: "Cancel Restore?") + } + } + + private var cancelAlertConfirmLabel: String { + switch kind { + case .backup: return String(localized: "Cancel Backup") + case .restore: return String(localized: "Cancel Restore") + } + } + + private var cancelAlertMessage: String { + switch kind { + case .backup: return String(localized: "The partial backup file will be removed.") + case .restore: return String(localized: "The target database may be left in a partial state.") } } @@ -66,11 +105,22 @@ struct BackupProgressSheet: View { } } -#Preview { +#Preview("Backup") { BackupProgressSheet( + kind: .backup, database: "production", bytesWritten: 12_345_678, isCancelling: false, onCancel: {} ) } + +#Preview("Restore") { + BackupProgressSheet( + kind: .restore, + database: "production", + bytesWritten: 0, + isCancelling: false, + onCancel: {} + ) +} diff --git a/TablePro/Views/Backup/BackupResultSheet.swift b/TablePro/Views/Backup/BackupResultSheet.swift index f0045ab9d..f44dbf49b 100644 --- a/TablePro/Views/Backup/BackupResultSheet.swift +++ b/TablePro/Views/Backup/BackupResultSheet.swift @@ -2,16 +2,25 @@ // BackupResultSheet.swift // TablePro // +// Shared result sheet for the backup and restore flows. +// import SwiftUI struct BackupResultSheet: View { + enum Kind { + case backup + case restore + } + enum Outcome { - case success(database: String, destination: URL, bytes: Int64) + case backupSuccess(database: String, destination: URL, bytes: Int64) + case restoreSuccess(database: String, source: URL) case failure(message: String) case cancelled } + let kind: Kind let outcome: Outcome let onClose: () -> Void let onShowInFinder: (() -> Void)? @@ -37,7 +46,7 @@ struct BackupResultSheet: View { } HStack(spacing: 12) { - if case .success = outcome, let onShowInFinder { + if case .backupSuccess = outcome, let onShowInFinder { Button(String(localized: "Show in Finder")) { onShowInFinder() onClose() @@ -58,7 +67,7 @@ struct BackupResultSheet: View { @ViewBuilder private var icon: some View { switch outcome { - case .success: + case .backupSuccess, .restoreSuccess: Image(systemName: "checkmark.circle.fill") case .failure: Image(systemName: "exclamationmark.triangle.fill") @@ -69,7 +78,7 @@ struct BackupResultSheet: View { private var tintColor: Color { switch outcome { - case .success: return Color(nsColor: .systemGreen) + case .backupSuccess, .restoreSuccess: return Color(nsColor: .systemGreen) case .failure: return Color(nsColor: .systemOrange) case .cancelled: return Color(nsColor: .systemGray) } @@ -77,15 +86,26 @@ struct BackupResultSheet: View { private var title: String { switch outcome { - case .success: return String(localized: "Backup Complete") - case .failure: return String(localized: "Backup Failed") - case .cancelled: return String(localized: "Backup Cancelled") + case .backupSuccess: + return String(localized: "Backup Complete") + case .restoreSuccess: + return String(localized: "Restore Complete") + case .failure: + switch kind { + case .backup: return String(localized: "Backup Failed") + case .restore: return String(localized: "Restore Failed") + } + case .cancelled: + switch kind { + case .backup: return String(localized: "Backup Cancelled") + case .restore: return String(localized: "Restore Cancelled") + } } } private var detail: String? { switch outcome { - case .success(let database, let destination, let bytes): + case .backupSuccess(let database, let destination, let bytes): let size = ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) return String( format: String(localized: "Saved %@ of \u{201C}%@\u{201D} to %@"), @@ -93,17 +113,28 @@ struct BackupResultSheet: View { database, destination.path ) + case .restoreSuccess(let database, let source): + return String( + format: String(localized: "Restored \u{201C}%@\u{201D} from %@"), + database, + source.path + ) case .failure(let message): return message case .cancelled: - return nil + switch kind { + case .backup: return nil + case .restore: + return String(localized: "The target database may be in a partial state. Review the database and clean up as needed.") + } } } } -#Preview("Success") { +#Preview("Backup Success") { BackupResultSheet( - outcome: .success( + kind: .backup, + outcome: .backupSuccess( database: "production", destination: URL(fileURLWithPath: "/Users/me/Desktop/production-2025-05-11-120000.dump"), bytes: 12_345_678 @@ -113,9 +144,22 @@ struct BackupResultSheet: View { ) } -#Preview("Failure") { +#Preview("Restore Success") { + BackupResultSheet( + kind: .restore, + outcome: .restoreSuccess( + database: "production", + source: URL(fileURLWithPath: "/Users/me/Desktop/production.dump") + ), + onClose: {}, + onShowInFinder: nil + ) +} + +#Preview("Restore Failure") { BackupResultSheet( - outcome: .failure(message: "pg_dump: error: connection to server failed: FATAL: password authentication failed for user \"postgres\""), + kind: .restore, + outcome: .failure(message: "pg_restore: error: could not connect to database \"missing\": FATAL: database does not exist"), onClose: {}, onShowInFinder: nil ) diff --git a/TablePro/Views/Backup/RestoreDatabaseFlow.swift b/TablePro/Views/Backup/RestoreDatabaseFlow.swift new file mode 100644 index 000000000..a43677fc5 --- /dev/null +++ b/TablePro/Views/Backup/RestoreDatabaseFlow.swift @@ -0,0 +1,134 @@ +// +// RestoreDatabaseFlow.swift +// TablePro +// +// Sheet body for the Restore Database menu item. The .dump file is +// chosen up front (NSOpenPanel from MainContentCommandActions), so this +// flow opens directly on `DatabaseSwitcherSheet` in `.restore` mode and +// drives `PostgresRestoreService` through to a result. +// + +import AppKit +import SwiftUI + +struct RestoreDatabaseFlow: View { + @Binding var isPresented: Bool + let connection: DatabaseConnection + let initialDatabase: String + let sourceURL: URL + + @State private var service = PostgresRestoreService() + @State private var phase: Phase = .pickDatabase + + private enum Phase: Equatable { + case pickDatabase + case running(database: String) + case finished(database: String, source: URL) + case failed(message: String) + case cancelled + } + + var body: some View { + Group { + switch phase { + case .pickDatabase: + pickerView + case .running(let database): + BackupProgressSheet( + kind: .restore, + database: database, + bytesWritten: 0, + isCancelling: service.state == .cancelling, + onCancel: { service.cancel() } + ) + case .finished(let database, let source): + BackupResultSheet( + kind: .restore, + outcome: .restoreSuccess(database: database, source: source), + onClose: { isPresented = false }, + onShowInFinder: nil + ) + case .failed(let message): + BackupResultSheet( + kind: .restore, + outcome: .failure(message: message), + onClose: { isPresented = false }, + onShowInFinder: nil + ) + case .cancelled: + BackupResultSheet( + kind: .restore, + outcome: .cancelled, + onClose: { isPresented = false }, + onShowInFinder: nil + ) + } + } + .onChange(of: serviceState) { _, newState in + handleServiceStateChange(newState) + } + } + + private var pickerView: some View { + VStack(spacing: 0) { + sourceBanner + Divider() + DatabaseSwitcherSheet( + isPresented: $isPresented, + mode: .restore, + currentDatabase: initialDatabase, + databaseType: connection.type, + connectionId: connection.id, + onSelect: { database in + Task { await startRestore(database: database) } + } + ) + } + } + + private var sourceBanner: some View { + HStack(spacing: 8) { + Image(systemName: "doc.zipper") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text("Restore from") + .font(.caption) + .foregroundStyle(.secondary) + Text(sourceURL.lastPathComponent) + .font(.body) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(width: 420, alignment: .leading) + } + + /// Hashable snapshot of `service.state` so SwiftUI's `onChange` fires on every transition. + private var serviceState: PostgresRestoreService.State { service.state } + + private func handleServiceStateChange(_ state: PostgresRestoreService.State) { + switch state { + case .running(let database, _): + phase = .running(database: database) + case .finished(let database, let source): + phase = .finished(database: database, source: source) + case .failed(let message): + phase = .failed(message: message) + case .cancelled: + phase = .cancelled + case .idle, .cancelling: + break + } + } + + private func startRestore(database: String) async { + do { + try await service.start(connection: connection, database: database, source: sourceURL) + } catch { + phase = .failed(message: error.localizedDescription) + } + } +} diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index e64d5403a..d6c5ce166 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -12,10 +12,19 @@ import TableProPluginKit struct DatabaseSwitcherSheet: View { /// What the sheet is being used for. `switch` (default) switches the active - /// database/schema; `backup` picks a database to feed into a backup flow. + /// database/schema; `backup` picks a database to feed into a backup flow; + /// `restore` picks the target database for a restore flow. enum Mode { case `switch` case backup + case restore + } + + /// Modes that pick a database for an out-of-band flow (backup / restore). + /// These share UI affordances: schemas tab hidden, create/drop hidden, + /// the primary button doesn't auto-dismiss. + private var isHandoffMode: Bool { + mode == .backup || mode == .restore } @Binding var isPresented: Bool @@ -77,8 +86,8 @@ struct DatabaseSwitcherSheet: View { var body: some View { VStack(spacing: 0) { - // Databases / Schemas toggle (PostgreSQL only); always Databases in backup mode. - if mode == .switch, PluginManager.shared.supportsSchemaSwitching(for: databaseType) { + // Databases / Schemas toggle (PostgreSQL only); hidden for handoff flows. + if !isHandoffMode, PluginManager.shared.supportsSchemaSwitching(for: databaseType) { Picker("", selection: $viewModel.mode) { Text(String(localized: "Databases")) .tag(DatabaseSwitcherViewModel.Mode.database) @@ -184,7 +193,7 @@ struct DatabaseSwitcherSheet: View { .buttonStyle(.borderless) .help(String(localized: "Refresh database list")) - if mode == .switch, !isSchemaMode, supportsCreateDatabase { + if !isHandoffMode, !isSchemaMode, supportsCreateDatabase { Button(action: { showCreateDialog = true }) { Image(systemName: "plus") .frame(width: 24, height: 24) @@ -194,7 +203,7 @@ struct DatabaseSwitcherSheet: View { } // Drop - if mode == .switch, !isSchemaMode, PluginManager.shared.supportsDropDatabase(for: databaseType) { + if !isHandoffMode, !isSchemaMode, PluginManager.shared.supportsDropDatabase(for: databaseType) { Button(action: { initiateDropForSelected() }) { Image(systemName: "trash") .frame(width: 24, height: 24) @@ -379,6 +388,8 @@ struct DatabaseSwitcherSheet: View { : String(localized: "Open Database") case .backup: return String(localized: "Back Up Database") + case .restore: + return String(localized: "Restore Database") } } @@ -386,13 +397,14 @@ struct DatabaseSwitcherSheet: View { switch mode { case .switch: return String(localized: "Open") case .backup: return String(localized: "Back Up\u{2026}") + case .restore: return String(localized: "Restore\u{2026}") } } private var primaryButtonDisabled: Bool { guard let selected = viewModel.selectedDatabase else { return true } // In switch mode, picking the already-active database/schema is a no-op. - // In backup mode, the active database is a perfectly fine target. + // In backup/restore modes the active database is a valid target. if mode == .switch, selected == activeName { return true } return false } @@ -447,10 +459,10 @@ struct DatabaseSwitcherSheet: View { private func openSelectedDatabase() { guard let database = viewModel.selectedDatabase else { return } - // Backup mode: hand the selection off to the parent flow without + // Backup/restore: hand the selection off to the parent flow without // dismissing. The host sheet stays mounted and transitions to the - // next step (save panel, then progress). - if mode == .backup { + // next step (save/open panel, then progress). + if isHandoffMode { onSelect(database) return } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 44314db5b..9624e7149 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -697,6 +697,32 @@ final class MainContentCommandActions { connection.type == .postgresql || connection.type == .redshift } + /// Restore is offered for the same database types as backup. The actual + /// flow opens NSOpenPanel for the .dump file first, then opens the + /// `restoreDatabase` sheet to pick the target database. + var supportsRestore: Bool { supportsBackup } + + func restoreDatabase() { + Task { @MainActor in + let openPanel = NSOpenPanel() + openPanel.canChooseFiles = true + openPanel.canChooseDirectories = false + openPanel.allowsMultipleSelection = false + openPanel.title = String(localized: "Choose Backup File") + openPanel.prompt = String(localized: "Choose") + openPanel.message = String(localized: "Select a backup file created with pg_dump custom or directory format.") + + let response: NSApplication.ModalResponse + if let window = self.window { + response = await openPanel.beginSheetModal(for: window) + } else { + response = openPanel.runModal() + } + guard response == .OK, let url = openPanel.url else { return } + coordinator?.activeSheet = .restoreDatabase(fileURL: url) + } + } + func saveAsFavorite() { coordinator?.saveCurrentQueryAsFavorite() } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 62d781411..9075fc7ff 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -47,6 +47,7 @@ enum ActiveSheet: Identifiable { case importDialog case exportQueryResults case backupDatabase + case restoreDatabase(fileURL: URL) case maintenance(operation: String, tableName: String) var id: String { @@ -56,6 +57,7 @@ enum ActiveSheet: Identifiable { case .importDialog: "importDialog" case .exportQueryResults: "exportQueryResults" case .backupDatabase: "backupDatabase" + case .restoreDatabase(let fileURL): "restoreDatabase-\(fileURL.path)" case .maintenance(let operation, let tableName): "maintenance-\(operation)-\(tableName)" } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 1e9a5f4af..c39e818c3 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -205,6 +205,14 @@ struct MainContentView: View { initialDatabase: DatabaseManager.shared.session(for: connection.id)?.currentDatabase ?? connection.database ) + case .restoreDatabase(let fileURL): + RestoreDatabaseFlow( + isPresented: dismissBinding, + connection: connectionWithCurrentDatabase, + initialDatabase: DatabaseManager.shared.session(for: connection.id)?.currentDatabase + ?? connection.database, + sourceURL: fileURL + ) case .maintenance(let operation, let tableName): MaintenanceSheet( operation: operation, diff --git a/TablePro/Views/Settings/TerminalSettingsView.swift b/TablePro/Views/Settings/TerminalSettingsView.swift index 50df78add..0c1535198 100644 --- a/TablePro/Views/Settings/TerminalSettingsView.swift +++ b/TablePro/Views/Settings/TerminalSettingsView.swift @@ -126,7 +126,8 @@ struct TerminalSettingsView: View { ForEach(Self.terminalDatabaseTypes, id: \.rawValue) { dbType in cliPathRow(for: dbType) } - pgDumpPathRow + postgresToolRow(key: TerminalSettings.pgDumpCliPathKey, binaryName: "pg_dump") + postgresToolRow(key: TerminalSettings.pgRestoreCliPathKey, binaryName: "pg_restore") } } footer: { Text("Override auto-detected CLI paths per database type.") From 3730f89d9dea3e080a334ba0a8c3094a9b9aad36 Mon Sep 17 00:00:00 2001 From: Samir Mammadhasanov Date: Mon, 11 May 2026 11:58:57 +0400 Subject: [PATCH 03/11] fix(backup): restore postgresToolRow helper missing from Restore commit --- .../Views/Settings/TerminalSettingsView.swift | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/TablePro/Views/Settings/TerminalSettingsView.swift b/TablePro/Views/Settings/TerminalSettingsView.swift index 0c1535198..4b4c256af 100644 --- a/TablePro/Views/Settings/TerminalSettingsView.swift +++ b/TablePro/Views/Settings/TerminalSettingsView.swift @@ -150,19 +150,21 @@ struct TerminalSettingsView: View { } @ViewBuilder - private var pgDumpPathRow: some View { - let key = TerminalSettings.pgDumpCliPathKey + private func postgresToolRow(key: String, binaryName: String) -> some View { let binding = Binding( get: { settings.cliPaths[key] ?? "" }, set: { settings.cliPaths[key] = $0.isEmpty ? nil : $0 } ) - let resolved = resolvedPaths[key] ?? "pg_dump" - TextField("pg_dump", text: binding, prompt: Text(resolved)) + let resolved = resolvedPaths[key] ?? binaryName + TextField(binaryName, text: binding, prompt: Text(resolved)) } private func resolveAllCliPaths() async { let dbTypes = Self.terminalDatabaseTypes - let pgDumpKey = TerminalSettings.pgDumpCliPathKey + let postgresTools: [(key: String, binary: String)] = [ + (TerminalSettings.pgDumpCliPathKey, "pg_dump"), + (TerminalSettings.pgRestoreCliPathKey, "pg_restore") + ] let results = await withTaskGroup(of: (String, String).self) { group in for dbType in dbTypes { group.addTask { @@ -173,11 +175,13 @@ struct TerminalSettingsView: View { return (dbType.rawValue, resolved ?? name) } } - group.addTask { - let resolved = await Task.detached(priority: .utility) { - CLICommandResolver.findExecutable("pg_dump") - }.value - return (pgDumpKey, resolved ?? "pg_dump") + for tool in postgresTools { + group.addTask { + let resolved = await Task.detached(priority: .utility) { + CLICommandResolver.findExecutable(tool.binary) + }.value + return (tool.key, resolved ?? tool.binary) + } } var paths: [String: String] = [:] for await (key, value) in group { From e9d38c4d4114a225afd2d9c2b4856745a2d8e86e Mon Sep 17 00:00:00 2001 From: Samir Mammadhasanov Date: Mon, 11 May 2026 12:13:50 +0400 Subject: [PATCH 04/11] fix(backup): force database mode in backup/restore picker for PostgreSQL --- TablePro/ViewModels/DatabaseSwitcherViewModel.swift | 9 +++++++-- .../Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift | 9 ++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index 06414556d..f0d034ed0 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -60,14 +60,19 @@ final class DatabaseSwitcherViewModel { init( connectionId: UUID, currentDatabase: String?, currentSchema: String?, - databaseType: DatabaseType, services: AppServices = .live + databaseType: DatabaseType, services: AppServices = .live, + initialMode: Mode? = nil ) { self.connectionId = connectionId self.currentDatabase = currentDatabase self.currentSchema = currentSchema self.databaseType = databaseType self.services = services - self.mode = services.pluginManager.supportsSchemaSwitching(for: databaseType) ? .schema : .database + if let initialMode { + self.mode = initialMode + } else { + self.mode = services.pluginManager.supportsSchemaSwitching(for: databaseType) ? .schema : .database + } } // MARK: - Public Methods diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index d6c5ce166..1501c7b1a 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -75,12 +75,19 @@ struct DatabaseSwitcherSheet: View { self.connectionId = connectionId self.onSelect = onSelect self.onSelectSchema = onSelectSchema + // Backup and restore always operate at the database level (pg_dump + // dumps a whole database). Force .database so PostgreSQL doesn't + // open the picker in schema mode. + let initialMode: DatabaseSwitcherViewModel.Mode? = (mode == .backup || mode == .restore) + ? .database + : nil self._viewModel = State( wrappedValue: DatabaseSwitcherViewModel( connectionId: connectionId, currentDatabase: currentDatabase, currentSchema: currentSchema, - databaseType: databaseType + databaseType: databaseType, + initialMode: initialMode )) } From f21a3b27c439f5ecbd9c97c9e51fc37fabc02746 Mon Sep 17 00:00:00 2001 From: Samir Mammadhasanov Date: Mon, 11 May 2026 12:15:29 +0400 Subject: [PATCH 05/11] feat(backup): show progress immediately on submit + rename to Backup Dump / Restore Dump --- TablePro/Info.plist | 164 +++++++++--------- TablePro/Resources/Localizable.xcstrings | 156 ++++++++++++++++- TablePro/TablePro.entitlements | 16 +- TablePro/TableProApp.swift | 4 +- .../Views/Backup/BackupDatabaseFlow.swift | 3 + .../Views/Backup/BackupProgressSheet.swift | 12 +- TablePro/Views/Backup/BackupResultSheet.swift | 12 +- .../Views/Backup/RestoreDatabaseFlow.swift | 3 + .../DatabaseSwitcherSheet.swift | 8 +- 9 files changed, 262 insertions(+), 116 deletions(-) diff --git a/TablePro/Info.plist b/TablePro/Info.plist index d5e98b81d..79896fb55 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -4,10 +4,6 @@ AnalyticsHMACSecret $(ANALYTICS_HMAC_SECRET) - SUFeedURL - https://raw.githubusercontent.com/TableProApp/TablePro/main/appcast.xml - SUPublicEDKey - EongGFyuahKlYPZwgmnFx8nW3s1CqWlSSU5BDqY6n6Q= CFBundleDocumentTypes @@ -86,37 +82,92 @@ + CFBundleTypeExtensions + + duckdb + ddb + CFBundleTypeName DuckDB Database CFBundleTypeRole Editor LSHandlerRank Owner - CFBundleTypeExtensions - - duckdb - ddb - LSItemContentTypes com.tablepro.duckdb + CFBundleURLTypes + + + CFBundleTypeRole + Viewer + CFBundleURLName + com.TablePro.deeplink + CFBundleURLSchemes + + tablepro + + + + CFBundleTypeRole + Viewer + CFBundleURLName + com.TablePro.database-url + CFBundleURLSchemes + + postgresql + postgres + mysql + mariadb + sqlite + mongodb + redis + rediss + redshift + mongodb+srv + mssql + sqlserver + duckdb + cassandra + cql + scylladb + scylla + oracle + clickhouse + ch + etcd + etcds + d1 + libsql + + + + NSUserActivityTypes + + com.TablePro.viewConnection + com.TablePro.viewTable + + SUFeedURL + https://raw.githubusercontent.com/TableProApp/TablePro/main/appcast.xml + SUPublicEDKey + EongGFyuahKlYPZwgmnFx8nW3s1CqWlSSU5BDqY6n6Q= UTExportedTypeDeclarations - UTTypeIdentifier - com.tablepro.sql - UTTypeDescription - SQL File - UTTypeIconFile - SQLDocument UTTypeConformsTo public.sql public.plain-text + UTTypeDescription + SQL File + UTTypeIconFile + SQLDocument + UTTypeIdentifier + com.tablepro.sql UTTypeTagSpecification public.filename-extension @@ -126,16 +177,16 @@ - UTTypeIdentifier - com.tablepro.sqlite-db - UTTypeDescription - SQLite Database UTTypeConformsTo com.apple.sqlite3 public.database public.data + UTTypeDescription + SQLite Database + UTTypeIdentifier + com.tablepro.sqlite-db UTTypeTagSpecification public.filename-extension @@ -150,15 +201,15 @@ - UTTypeIdentifier - com.tablepro.duckdb - UTTypeDescription - DuckDB Database UTTypeConformsTo public.database public.data + UTTypeDescription + DuckDB Database + UTTypeIdentifier + com.tablepro.duckdb UTTypeTagSpecification public.filename-extension @@ -169,15 +220,15 @@ - UTTypeIdentifier - com.tablepro.connection-share - UTTypeDescription - TablePro Connection UTTypeConformsTo public.json public.data + UTTypeDescription + TablePro Connection + UTTypeIdentifier + com.tablepro.connection-share UTTypeTagSpecification public.filename-extension @@ -187,15 +238,15 @@ - UTTypeIdentifier - com.tablepro.plugin - UTTypeDescription - TablePro Plugin UTTypeConformsTo com.apple.bundle com.apple.package + UTTypeDescription + TablePro Plugin + UTTypeIdentifier + com.tablepro.plugin UTTypeTagSpecification public.filename-extension @@ -205,56 +256,5 @@ - NSUserActivityTypes - - com.TablePro.viewConnection - com.TablePro.viewTable - - CFBundleURLTypes - - - CFBundleURLName - com.TablePro.deeplink - CFBundleURLSchemes - - tablepro - - CFBundleTypeRole - Viewer - - - CFBundleURLName - com.TablePro.database-url - CFBundleURLSchemes - - postgresql - postgres - mysql - mariadb - sqlite - mongodb - redis - rediss - redshift - mongodb+srv - mssql - sqlserver - duckdb - cassandra - cql - scylladb - scylla - oracle - clickhouse - ch - etcd - etcds - d1 - libsql - - CFBundleTypeRole - Viewer - - diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 31b9b2917..11c532719 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -3515,6 +3515,9 @@ } } } + }, + "A backup is already running." : { + }, "A built-in plugin \"%@\" already provides this bundle ID" : { "localizations" : { @@ -3584,6 +3587,9 @@ } } } + }, + "A restore is already running." : { + }, "A sync conflict was detected and needs to be resolved." : { "localizations" : { @@ -7386,6 +7392,12 @@ } } } + }, + "Back Up Database" : { + + }, + "Back Up…" : { + }, "Background" : { "localizations" : { @@ -7409,6 +7421,27 @@ } } }, + "Backing Up Database" : { + "comment" : "A title for a progress sheet that is displayed when backing up a database.", + "isCommentAutoGenerated" : true + }, + "Backup Cancelled" : { + + }, + "Backup Complete" : { + "comment" : "A title for a backup result sheet when the backup was successful.", + "isCommentAutoGenerated" : true + }, + "Backup Failed" : { + + }, + "Backup..." : { + + }, + "Backups are only supported for PostgreSQL and Redshift connections." : { + "comment" : "Error message displayed when attempting to create a backup of a database that is not PostgreSQL or Redshift.", + "isCommentAutoGenerated" : true + }, "Badge Background" : { "localizations" : { "tr" : { @@ -8152,6 +8185,13 @@ } } } + }, + "Cancel Backup" : { + "comment" : "A button that cancels a backup.", + "isCommentAutoGenerated" : true + }, + "Cancel Backup?" : { + }, "Cancel Query" : { "localizations" : { @@ -8218,6 +8258,12 @@ } } } + }, + "Cancel Restore" : { + + }, + "Cancel Restore?" : { + }, "Cancelled" : { @@ -8243,6 +8289,9 @@ } } } + }, + "Cancelling…" : { + }, "Cannot connect to Ollama at %@. Is Ollama running?" : { "localizations" : { @@ -8899,6 +8948,10 @@ }, "Chinook (Sample)" : { + }, + "Choose" : { + "comment" : "Button text for choosing a backup file.", + "isCommentAutoGenerated" : true }, "Choose a certificate or key file" : { "localizations" : { @@ -9043,6 +9096,14 @@ }, "Choose AI provider and model" : { + }, + "Choose Backup File" : { + "comment" : "Title of the dialog that opens to choose a backup file.", + "isCommentAutoGenerated" : true + }, + "Choose where to save the backup of “%@”." : { + "comment" : "A message that appears in the body of a dialog box that appears when the user is prompted to choose a destination for a backup. The argument is the name of the database that is being backed up.", + "isCommentAutoGenerated" : true }, "Choose your client and follow the steps to connect it to TablePro." : { @@ -10985,6 +11046,12 @@ } } } + }, + "Connect to the database before starting a backup." : { + + }, + "Connect to the database before starting a restore." : { + }, "Connect to the internet to verify your license." : { "localizations" : { @@ -34084,6 +34151,20 @@ } } }, + "pg_dump exited with code %d" : { + "comment" : "A summary of an error that might occur when backing up a database.", + "isCommentAutoGenerated" : true + }, + "pg_dump was not found on this system. Install it with `brew install libpq` and link it, or set a custom path under Settings > Terminal > CLI Paths > pg_dump." : { + + }, + "pg_restore exited with code %d" : { + + }, + "pg_restore was not found on this system. Install it with `brew install libpq` and link it, or set a custom path under Settings > Terminal > CLI Paths > pg_restore." : { + "comment" : "Error message when pg_restore is not found on the system.", + "isCommentAutoGenerated" : true + }, "Pick the type of database you want to connect to." : { }, @@ -38652,6 +38733,26 @@ } } } + }, + "Restore Cancelled" : { + + }, + "Restore Complete" : { + + }, + "Restore Database" : { + + }, + "Restore Failed" : { + "comment" : "A title for a backup failure alert.", + "isCommentAutoGenerated" : true + }, + "Restore from" : { + "comment" : "A label displayed above the name of the source database.", + "isCommentAutoGenerated" : true + }, + "Restore is only supported for PostgreSQL and Redshift connections." : { + }, "Restore Last Filter" : { "localizations" : { @@ -38675,6 +38776,26 @@ } } }, + "Restore..." : { + + }, + "Restore…" : { + + }, + "Restored “%@” from %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restored “%1$@” from %2$@" + } + } + } + }, + "Restoring Database" : { + "comment" : "A title for a progress sheet that is displayed when restoring a database.", + "isCommentAutoGenerated" : true + }, "Restrict to a specific connection (UUID, optional)" : { }, @@ -39846,6 +39967,9 @@ } } } + }, + "Save Backup" : { + }, "Save Changes" : { "localizations" : { @@ -40123,6 +40247,18 @@ } } }, + "Saved %@ of “%@” to %@" : { + "comment" : "A detail line that shows the size of the backed-up database and the path of the backup destination.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Saved %1$@ of “%2$@” to %3$@" + } + } + } + }, "Saved Connections" : { "localizations" : { "tr" : { @@ -40897,6 +41033,9 @@ } } } + }, + "Select a backup file created with pg_dump custom or directory format." : { + }, "Select a Plugin" : { "localizations" : { @@ -46880,8 +47019,15 @@ } } }, + "The partial backup file will be removed." : { + "comment" : "A message displayed in a cancel confirmation alert for a backup.", + "isCommentAutoGenerated" : true + }, "The previous code wasn't accepted. Wait for your authenticator to refresh, then enter the new code." : { + }, + "The selected backup file is not readable." : { + }, "The server will be accessible from other devices on your network. Authentication and TLS are enabled automatically." : { "localizations" : { @@ -46905,6 +47051,14 @@ } } }, + "The target database may be in a partial state. Review the database and clean up as needed." : { + "comment" : "A message that appears when restoring a database and the user is advised to review and clean up the database.", + "isCommentAutoGenerated" : true + }, + "The target database may be left in a partial state." : { + "comment" : "A message displayed in a cancel confirmation alert for a database restore.", + "isCommentAutoGenerated" : true + }, "The text could not be parsed as JSON." : { "localizations" : { "tr" : { @@ -52125,5 +52279,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/TablePro/TablePro.entitlements b/TablePro/TablePro.entitlements index 1f880e34d..f61833375 100644 --- a/TablePro/TablePro.entitlements +++ b/TablePro/TablePro.entitlements @@ -2,27 +2,13 @@ - com.apple.application-identifier - D7HJ5TFYCU.com.TablePro - com.apple.developer.icloud-container-identifiers - - iCloud.com.TablePro - - com.apple.developer.icloud-services - - CloudKit - - com.apple.developer.icloud-container-environment - Production - com.apple.developer.team-identifier - D7HJ5TFYCU com.apple.security.app-sandbox com.apple.security.cs.disable-library-validation keychain-access-groups - D7HJ5TFYCU.com.TablePro.shared + $(AppIdentifierPrefix)com.TablePro.shared diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index cdf0d825c..aa0977c11 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -300,12 +300,12 @@ struct AppMenuCommands: Commands { || !(actions.map { PluginManager.shared.supportsImport(for: $0.currentDatabaseType) } ?? true) ) - Button(String(localized: "Backup...")) { + Button(String(localized: "Backup Dump...")) { actions?.backupDatabase() } .disabled(!(actions?.isConnected ?? false) || !(actions?.supportsBackup ?? false)) - Button(String(localized: "Restore...")) { + Button(String(localized: "Restore Dump...")) { actions?.restoreDatabase() } .disabled( diff --git a/TablePro/Views/Backup/BackupDatabaseFlow.swift b/TablePro/Views/Backup/BackupDatabaseFlow.swift index d0b4350e3..bae1d553d 100644 --- a/TablePro/Views/Backup/BackupDatabaseFlow.swift +++ b/TablePro/Views/Backup/BackupDatabaseFlow.swift @@ -133,6 +133,9 @@ struct BackupDatabaseFlow: View { return } + // Show the progress sheet immediately so the user sees feedback while + // pg_dump is being located and started. + phase = .running(database: database) do { try await service.start(connection: connection, database: database, destination: url) } catch { diff --git a/TablePro/Views/Backup/BackupProgressSheet.swift b/TablePro/Views/Backup/BackupProgressSheet.swift index 2875aca29..fa6d54caf 100644 --- a/TablePro/Views/Backup/BackupProgressSheet.swift +++ b/TablePro/Views/Backup/BackupProgressSheet.swift @@ -74,22 +74,22 @@ struct BackupProgressSheet: View { private var titleString: String { switch kind { - case .backup: return String(localized: "Backing Up Database") - case .restore: return String(localized: "Restoring Database") + case .backup: return String(localized: "Creating Backup Dump") + case .restore: return String(localized: "Restoring Dump") } } private var cancelAlertTitle: String { switch kind { - case .backup: return String(localized: "Cancel Backup?") - case .restore: return String(localized: "Cancel Restore?") + case .backup: return String(localized: "Cancel Backup Dump?") + case .restore: return String(localized: "Cancel Restore Dump?") } } private var cancelAlertConfirmLabel: String { switch kind { - case .backup: return String(localized: "Cancel Backup") - case .restore: return String(localized: "Cancel Restore") + case .backup: return String(localized: "Cancel Backup Dump") + case .restore: return String(localized: "Cancel Restore Dump") } } diff --git a/TablePro/Views/Backup/BackupResultSheet.swift b/TablePro/Views/Backup/BackupResultSheet.swift index f44dbf49b..feab81309 100644 --- a/TablePro/Views/Backup/BackupResultSheet.swift +++ b/TablePro/Views/Backup/BackupResultSheet.swift @@ -87,18 +87,18 @@ struct BackupResultSheet: View { private var title: String { switch outcome { case .backupSuccess: - return String(localized: "Backup Complete") + return String(localized: "Backup Dump Complete") case .restoreSuccess: - return String(localized: "Restore Complete") + return String(localized: "Restore Dump Complete") case .failure: switch kind { - case .backup: return String(localized: "Backup Failed") - case .restore: return String(localized: "Restore Failed") + case .backup: return String(localized: "Backup Dump Failed") + case .restore: return String(localized: "Restore Dump Failed") } case .cancelled: switch kind { - case .backup: return String(localized: "Backup Cancelled") - case .restore: return String(localized: "Restore Cancelled") + case .backup: return String(localized: "Backup Dump Cancelled") + case .restore: return String(localized: "Restore Dump Cancelled") } } } diff --git a/TablePro/Views/Backup/RestoreDatabaseFlow.swift b/TablePro/Views/Backup/RestoreDatabaseFlow.swift index a43677fc5..e80637154 100644 --- a/TablePro/Views/Backup/RestoreDatabaseFlow.swift +++ b/TablePro/Views/Backup/RestoreDatabaseFlow.swift @@ -125,6 +125,9 @@ struct RestoreDatabaseFlow: View { } private func startRestore(database: String) async { + // Show the progress sheet immediately so the user sees feedback while + // pg_restore is being located and started. + phase = .running(database: database) do { try await service.start(connection: connection, database: database, source: sourceURL) } catch { diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 1501c7b1a..5282494a1 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -394,17 +394,17 @@ struct DatabaseSwitcherSheet: View { ? String(localized: "Open Schema") : String(localized: "Open Database") case .backup: - return String(localized: "Back Up Database") + return String(localized: "Backup Dump") case .restore: - return String(localized: "Restore Database") + return String(localized: "Restore Dump") } } private var primaryButtonLabel: String { switch mode { case .switch: return String(localized: "Open") - case .backup: return String(localized: "Back Up\u{2026}") - case .restore: return String(localized: "Restore\u{2026}") + case .backup: return String(localized: "Backup Dump\u{2026}") + case .restore: return String(localized: "Restore Dump\u{2026}") } } From 0b0912582e799d39fee3df11e6d5f245ea8dcf68 Mon Sep 17 00:00:00 2001 From: Samir Mammadhasanov Date: Mon, 11 May 2026 12:16:20 +0400 Subject: [PATCH 06/11] fix(release): restore Info.plist Sparkle keys and entitlements clobbered by Xcode --- TablePro/Info.plist | 164 ++++++++++++++++----------------- TablePro/TablePro.entitlements | 16 +++- 2 files changed, 97 insertions(+), 83 deletions(-) diff --git a/TablePro/Info.plist b/TablePro/Info.plist index 79896fb55..d5e98b81d 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -4,6 +4,10 @@ AnalyticsHMACSecret $(ANALYTICS_HMAC_SECRET) + SUFeedURL + https://raw.githubusercontent.com/TableProApp/TablePro/main/appcast.xml + SUPublicEDKey + EongGFyuahKlYPZwgmnFx8nW3s1CqWlSSU5BDqY6n6Q= CFBundleDocumentTypes @@ -82,92 +86,37 @@ - CFBundleTypeExtensions - - duckdb - ddb - CFBundleTypeName DuckDB Database CFBundleTypeRole Editor LSHandlerRank Owner - LSItemContentTypes - - com.tablepro.duckdb - - - - CFBundleURLTypes - - - CFBundleTypeRole - Viewer - CFBundleURLName - com.TablePro.deeplink - CFBundleURLSchemes + CFBundleTypeExtensions - tablepro + duckdb + ddb - - - CFBundleTypeRole - Viewer - CFBundleURLName - com.TablePro.database-url - CFBundleURLSchemes + LSItemContentTypes - postgresql - postgres - mysql - mariadb - sqlite - mongodb - redis - rediss - redshift - mongodb+srv - mssql - sqlserver - duckdb - cassandra - cql - scylladb - scylla - oracle - clickhouse - ch - etcd - etcds - d1 - libsql + com.tablepro.duckdb - NSUserActivityTypes - - com.TablePro.viewConnection - com.TablePro.viewTable - - SUFeedURL - https://raw.githubusercontent.com/TableProApp/TablePro/main/appcast.xml - SUPublicEDKey - EongGFyuahKlYPZwgmnFx8nW3s1CqWlSSU5BDqY6n6Q= UTExportedTypeDeclarations + UTTypeIdentifier + com.tablepro.sql + UTTypeDescription + SQL File + UTTypeIconFile + SQLDocument UTTypeConformsTo public.sql public.plain-text - UTTypeDescription - SQL File - UTTypeIconFile - SQLDocument - UTTypeIdentifier - com.tablepro.sql UTTypeTagSpecification public.filename-extension @@ -177,16 +126,16 @@ + UTTypeIdentifier + com.tablepro.sqlite-db + UTTypeDescription + SQLite Database UTTypeConformsTo com.apple.sqlite3 public.database public.data - UTTypeDescription - SQLite Database - UTTypeIdentifier - com.tablepro.sqlite-db UTTypeTagSpecification public.filename-extension @@ -201,15 +150,15 @@ + UTTypeIdentifier + com.tablepro.duckdb + UTTypeDescription + DuckDB Database UTTypeConformsTo public.database public.data - UTTypeDescription - DuckDB Database - UTTypeIdentifier - com.tablepro.duckdb UTTypeTagSpecification public.filename-extension @@ -220,15 +169,15 @@ + UTTypeIdentifier + com.tablepro.connection-share + UTTypeDescription + TablePro Connection UTTypeConformsTo public.json public.data - UTTypeDescription - TablePro Connection - UTTypeIdentifier - com.tablepro.connection-share UTTypeTagSpecification public.filename-extension @@ -238,15 +187,15 @@ + UTTypeIdentifier + com.tablepro.plugin + UTTypeDescription + TablePro Plugin UTTypeConformsTo com.apple.bundle com.apple.package - UTTypeDescription - TablePro Plugin - UTTypeIdentifier - com.tablepro.plugin UTTypeTagSpecification public.filename-extension @@ -256,5 +205,56 @@ + NSUserActivityTypes + + com.TablePro.viewConnection + com.TablePro.viewTable + + CFBundleURLTypes + + + CFBundleURLName + com.TablePro.deeplink + CFBundleURLSchemes + + tablepro + + CFBundleTypeRole + Viewer + + + CFBundleURLName + com.TablePro.database-url + CFBundleURLSchemes + + postgresql + postgres + mysql + mariadb + sqlite + mongodb + redis + rediss + redshift + mongodb+srv + mssql + sqlserver + duckdb + cassandra + cql + scylladb + scylla + oracle + clickhouse + ch + etcd + etcds + d1 + libsql + + CFBundleTypeRole + Viewer + + diff --git a/TablePro/TablePro.entitlements b/TablePro/TablePro.entitlements index f61833375..1f880e34d 100644 --- a/TablePro/TablePro.entitlements +++ b/TablePro/TablePro.entitlements @@ -2,13 +2,27 @@ + com.apple.application-identifier + D7HJ5TFYCU.com.TablePro + com.apple.developer.icloud-container-identifiers + + iCloud.com.TablePro + + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.icloud-container-environment + Production + com.apple.developer.team-identifier + D7HJ5TFYCU com.apple.security.app-sandbox com.apple.security.cs.disable-library-validation keychain-access-groups - $(AppIdentifierPrefix)com.TablePro.shared + D7HJ5TFYCU.com.TablePro.shared From 437c1b7db9a60faee7875ef77c53ca6c2b61dd77 Mon Sep 17 00:00:00 2001 From: Samir Mammadhasanov Date: Mon, 11 May 2026 12:25:45 +0400 Subject: [PATCH 07/11] fix(backup): center-align result detail text --- TablePro/Views/Backup/BackupResultSheet.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Backup/BackupResultSheet.swift b/TablePro/Views/Backup/BackupResultSheet.swift index feab81309..69bcb15c9 100644 --- a/TablePro/Views/Backup/BackupResultSheet.swift +++ b/TablePro/Views/Backup/BackupResultSheet.swift @@ -41,7 +41,7 @@ struct BackupResultSheet: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) .lineLimit(8) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .center) .textSelection(.enabled) } From 83f869df2bca54dab26403a56a6e7e8871ea168e Mon Sep 17 00:00:00 2001 From: Samir Mammadhasanov Date: Mon, 11 May 2026 13:23:43 +0400 Subject: [PATCH 08/11] refactor(backup): consolidate dump service, address review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds PostgresBackupService and PostgresRestoreService into a single PostgresDumpService with a Kind enum (.backup / .restore). One state shape, one error shape, one stderr cap, one SSL switch, one termination handler. Other review-driven changes: - Inject a `PostgresDumpRunner` so the state machine can be exercised in tests without launching real subprocesses; `ProcessPostgresDumpRunner` is the production runner. - Stderr now accumulates entirely off the main actor inside the readabilityHandler closure (NSLock-guarded), with a single MainActor hop carrying the final string back through the termination continuation. - Cancel alert reads "Keep Backing Up" / "Keep Restoring" instead of "Continue". - NSOpenPanel for restore now has allowedContentTypes = [.dump, .data] and drops the "directory format" claim (canChooseDirectories is false). - Unified flow: both file panels are now sub-sheets of the SwiftUI sheet. Restore opens NSOpenPanel from inside the sheet on first appearance (with a Change… link in the source banner) instead of pre-opening before the sheet exists. - Real progress bar for backup: queries pg_database_size up front and feeds it as totalBytes; BackupProgressSheet renders a determinate bar capped at 95% (the dump file is smaller than the database thanks to compression). Tests: command construction (args/env/SSL/host fallback/password) and state machine transitions (success, failure, cancel, double-run guard, empty-stderr fallback) via a FakeDumpRunner. Plugin boundary move (item 1 of the review) is deferred to a follow-up commit. That change requires a PluginDatabaseDriver method, a plugin kit ABI bump, and Info.plist updates across every plugin, and is large enough to deserve its own review. --- .../Core/Database/PostgresBackupService.swift | 207 -------- .../Core/Database/PostgresDumpService.swift | 441 ++++++++++++++++++ .../Database/PostgresRestoreService.swift | 195 -------- TablePro/Resources/Localizable.xcstrings | 83 ++-- .../Views/Backup/BackupDatabaseFlow.swift | 58 +-- .../Views/Backup/BackupProgressSheet.swift | 53 ++- .../Views/Backup/RestoreDatabaseFlow.swift | 124 +++-- .../Main/MainContentCommandActions.swift | 19 +- .../Views/Main/MainContentCoordinator.swift | 4 +- TablePro/Views/Main/MainContentView.swift | 5 +- .../Database/PostgresDumpServiceTests.swift | 319 +++++++++++++ 11 files changed, 987 insertions(+), 521 deletions(-) delete mode 100644 TablePro/Core/Database/PostgresBackupService.swift create mode 100644 TablePro/Core/Database/PostgresDumpService.swift delete mode 100644 TablePro/Core/Database/PostgresRestoreService.swift create mode 100644 TableProTests/Database/PostgresDumpServiceTests.swift diff --git a/TablePro/Core/Database/PostgresBackupService.swift b/TablePro/Core/Database/PostgresBackupService.swift deleted file mode 100644 index d4300c6ee..000000000 --- a/TablePro/Core/Database/PostgresBackupService.swift +++ /dev/null @@ -1,207 +0,0 @@ -// -// PostgresBackupService.swift -// TablePro -// - -import Foundation -import Observation -import os - -@MainActor -@Observable -final class PostgresBackupService { - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "PostgresBackupService") - - enum State: Equatable { - case idle - case running(database: String, bytesWritten: Int64) - case cancelling - case finished(database: String, destination: URL, bytesWritten: Int64) - case failed(message: String) - case cancelled - } - - enum BackupError: LocalizedError { - case pgDumpNotFound - case unsupportedDatabase - case noSession - case alreadyRunning - - var errorDescription: String? { - switch self { - case .pgDumpNotFound: - return String(localized: """ - pg_dump was not found on this system. Install it with `brew install libpq` and \ - link it, or set a custom path under Settings > Terminal > CLI Paths > pg_dump. - """) - case .unsupportedDatabase: - return String(localized: "Backups are only supported for PostgreSQL and Redshift connections.") - case .noSession: - return String(localized: "Connect to the database before starting a backup.") - case .alreadyRunning: - return String(localized: "A backup is already running.") - } - } - } - - private(set) var state: State = .idle - - @ObservationIgnored private var process: Process? - @ObservationIgnored private var stderrBuffer = Data() - @ObservationIgnored private var byteSizeTask: Task? - - /// Returns the resolved pg_dump executable path, honoring the user-configured override. - nonisolated static func resolvePgDumpPath(customPath: String?) -> String? { - CLICommandResolver.findExecutable("pg_dump", customPath: customPath) - } - - private static func pgSSLMode(_ mode: SSLMode) -> String { - switch mode { - case .disabled: return "disable" - case .preferred: return "prefer" - case .required: return "require" - case .verifyCa: return "verify-ca" - case .verifyIdentity: return "verify-full" - } - } - - func start(connection: DatabaseConnection, database: String, destination: URL) async throws { - if case .running = state { throw BackupError.alreadyRunning } - if case .cancelling = state { throw BackupError.alreadyRunning } - - guard connection.type == .postgresql || connection.type == .redshift else { - throw BackupError.unsupportedDatabase - } - - let session = DatabaseManager.shared.session(for: connection.id) - guard session?.isConnected == true else { - throw BackupError.noSession - } - - let effective = session?.effectiveConnection ?? connection - - let customPath = AppSettingsManager.shared.terminal.cliPaths[TerminalSettings.pgDumpCliPathKey]?.nilIfEmpty - guard let pgDumpPath = Self.resolvePgDumpPath(customPath: customPath) else { - throw BackupError.pgDumpNotFound - } - - let password = ConnectionStorage.shared.loadPassword(for: connection.id) ?? session?.cachedPassword - - let proc = Process() - proc.executableURL = URL(fileURLWithPath: pgDumpPath) - - var args: [String] = [ - "-Fc", - "--no-password", - "-h", effective.host.isEmpty ? "127.0.0.1" : effective.host, - "-p", String(effective.port), - "-d", database, - "-f", destination.path - ] - if !effective.username.isEmpty { - args.append(contentsOf: ["-U", effective.username]) - } - proc.arguments = args - - var env = ProcessInfo.processInfo.environment - if let password, !password.isEmpty { - env["PGPASSWORD"] = password - } - if effective.sslConfig.isEnabled { - env["PGSSLMODE"] = Self.pgSSLMode(effective.sslConfig.mode) - } - proc.environment = env - - let stderrPipe = Pipe() - proc.standardError = stderrPipe - proc.standardOutput = FileHandle.nullDevice - - stderrBuffer = Data() - stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let chunk = handle.availableData - guard !chunk.isEmpty else { return } - Task { @MainActor [weak self] in - self?.stderrBuffer.append(chunk) - if self?.stderrBuffer.count ?? 0 > 64_000 { - let trimmed = self?.stderrBuffer.suffix(64_000) ?? Data() - self?.stderrBuffer = Data(trimmed) - } - } - } - - let dbName = database - proc.terminationHandler = { [weak self] terminated in - stderrPipe.fileHandleForReading.readabilityHandler = nil - Task { @MainActor [weak self] in - self?.handleTermination(terminated, database: dbName, destination: destination) - } - } - - do { - try proc.run() - } catch { - throw BackupError.pgDumpNotFound - } - - self.process = proc - state = .running(database: database, bytesWritten: 0) - startByteSizePolling(url: destination, database: database) - - Self.logger.info("pg_dump started pid=\(proc.processIdentifier, privacy: .public) db=\(dbName, privacy: .public)") - } - - func cancel() { - guard case .running = state, let proc = process else { return } - state = .cancelling - proc.terminate() - } - - private func handleTermination(_ proc: Process, database: String, destination: URL) { - byteSizeTask?.cancel() - byteSizeTask = nil - process = nil - - let exitCode = proc.terminationStatus - let bytes = (try? FileManager.default.attributesOfItem(atPath: destination.path)[.size] as? Int64) ?? 0 - - if exitCode == 0 { - state = .finished(database: database, destination: destination, bytesWritten: bytes) - Self.logger.info("pg_dump finished bytes=\(bytes) db=\(database, privacy: .public)") - return - } - - if case .cancelling = state { - try? FileManager.default.removeItem(at: destination) - state = .cancelled - Self.logger.notice("pg_dump cancelled db=\(database, privacy: .public)") - return - } - - try? FileManager.default.removeItem(at: destination) - let stderrText = String(data: stderrBuffer, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let summary = stderrText.isEmpty - ? String(format: String(localized: "pg_dump exited with code %d"), Int(exitCode)) - : stderrText - state = .failed(message: summary) - Self.logger.error("pg_dump failed code=\(exitCode) db=\(database, privacy: .public) stderr=\(stderrText, privacy: .public)") - } - - private func startByteSizePolling(url: URL, database: String) { - byteSizeTask?.cancel() - byteSizeTask = Task { @MainActor [weak self] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 250_000_000) - guard let self else { return } - guard case .running = self.state else { return } - let size = (try? FileManager.default - .attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0 - self.state = .running(database: database, bytesWritten: size) - } - } - } -} - -private extension String { - var nilIfEmpty: String? { isEmpty ? nil : self } -} diff --git a/TablePro/Core/Database/PostgresDumpService.swift b/TablePro/Core/Database/PostgresDumpService.swift new file mode 100644 index 000000000..267d734db --- /dev/null +++ b/TablePro/Core/Database/PostgresDumpService.swift @@ -0,0 +1,441 @@ +// +// PostgresDumpService.swift +// TablePro +// +// Consolidated backup + restore state machine for PostgreSQL connections. +// The actual Process execution is delegated to a `DumpRunner` so the +// state machine can be exercised in tests with a fake runner. +// + +import Foundation +import Observation +import os + +// MARK: - Public Types + +/// What the service is doing: dump (back up) a database or restore a dump file. +enum PostgresDumpKind: Equatable, Sendable { + case backup + case restore +} + +/// Observable state of a backup or restore. +enum PostgresDumpState: Equatable { + case idle + case running(database: String, fileURL: URL, bytesProcessed: Int64, totalBytes: Int64?) + case cancelling + case finished(database: String, fileURL: URL, bytesProcessed: Int64) + case failed(message: String) + case cancelled +} + +enum PostgresDumpError: LocalizedError, Equatable { + case binaryNotFound(name: String) + case unsupportedDatabase + case noSession + case alreadyRunning + case sourceUnreadable + + var errorDescription: String? { + switch self { + case .binaryNotFound(let name): + return String( + format: String(localized: """ + %@ was not found on this system. Install it with `brew install libpq` and \ + link it, or set a custom path under Settings > Terminal > CLI Paths > %@. + """), + name, name + ) + case .unsupportedDatabase: + return String(localized: "Dump operations are only supported for PostgreSQL and Redshift connections.") + case .noSession: + return String(localized: "Connect to the database before starting this operation.") + case .alreadyRunning: + return String(localized: "An operation is already running.") + case .sourceUnreadable: + return String(localized: "The selected backup file is not readable.") + } + } +} + +/// Parameters for a single backup or restore command. +struct PostgresDumpCommand: Equatable { + let executable: URL + let arguments: [String] + let environment: [String: String] + let stderrByteCap: Int +} + +/// Captured terminal state of a finished/cancelled subprocess. +struct PostgresDumpRunResult: Equatable { + let exitCode: Int32 + let stderr: String + let wasCancelled: Bool +} + +/// Spawns and supervises a single subprocess. Abstracted so the dump +/// state machine can be tested without launching real processes. +protocol PostgresDumpRunner: AnyObject { + /// Launches the command. Throws synchronously if the binary can't be spawned. + /// `result` returns the final outcome when the process exits. + func start(_ command: PostgresDumpCommand) throws + /// Sends SIGTERM. Safe to call multiple times. + func cancel() + /// Resolves once the process has terminated (normally or via cancel). + var result: PostgresDumpRunResult { get async } +} + +// MARK: - Service + +@MainActor +@Observable +final class PostgresDumpService { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "PostgresDumpService") + + let kind: PostgresDumpKind + private(set) var state: PostgresDumpState = .idle + + @ObservationIgnored private let runnerFactory: () -> any PostgresDumpRunner + @ObservationIgnored private var runner: (any PostgresDumpRunner)? + @ObservationIgnored private var byteSizeTask: Task? + + /// Default initializer uses the real `Process`-backed runner. + init(kind: PostgresDumpKind) { + self.kind = kind + self.runnerFactory = { ProcessPostgresDumpRunner() } + } + + /// Test-friendly initializer that injects a custom runner factory. + init(kind: PostgresDumpKind, runnerFactory: @escaping () -> any PostgresDumpRunner) { + self.kind = kind + self.runnerFactory = runnerFactory + } + + /// Starts the operation. `fileURL` is the destination for `.backup` and + /// the source for `.restore`. `totalBytesEstimate` enables a determinate + /// progress bar (used by backup; restore stays indeterminate). + /// + /// This entry point resolves dependencies from app singletons + /// (`DatabaseManager`, `ConnectionStorage`, `AppSettingsManager`, + /// `CLICommandResolver`). Tests should use `run(command:database:fileURL:totalBytesEstimate:)` + /// directly with a fake runner. + func start( + connection: DatabaseConnection, + database: String, + fileURL: URL, + totalBytesEstimate: Int64? = nil + ) async throws { + if case .running = state { throw PostgresDumpError.alreadyRunning } + if case .cancelling = state { throw PostgresDumpError.alreadyRunning } + + guard connection.type == .postgresql || connection.type == .redshift else { + throw PostgresDumpError.unsupportedDatabase + } + + let session = DatabaseManager.shared.session(for: connection.id) + guard session?.isConnected == true else { throw PostgresDumpError.noSession } + + if kind == .restore { + guard FileManager.default.isReadableFile(atPath: fileURL.path) else { + throw PostgresDumpError.sourceUnreadable + } + } + + let effective = session?.effectiveConnection ?? connection + let password = ConnectionStorage.shared.loadPassword(for: connection.id) ?? session?.cachedPassword + + let cliKey: String + let binaryName: String + switch kind { + case .backup: + cliKey = TerminalSettings.pgDumpCliPathKey + binaryName = "pg_dump" + case .restore: + cliKey = TerminalSettings.pgRestoreCliPathKey + binaryName = "pg_restore" + } + let customPath = AppSettingsManager.shared.terminal.cliPaths[cliKey]?.nilIfEmpty + guard let resolvedPath = CLICommandResolver.findExecutable(binaryName, customPath: customPath) else { + throw PostgresDumpError.binaryNotFound(name: binaryName) + } + + let command = Self.buildCommand( + kind: kind, + executable: URL(fileURLWithPath: resolvedPath), + effective: effective, + database: database, + fileURL: fileURL, + password: password + ) + + try run( + command: command, + database: database, + fileURL: fileURL, + totalBytesEstimate: totalBytesEstimate + ) + Self.logger.info("\(binaryName, privacy: .public) started db=\(database, privacy: .public)") + } + + /// Test-friendly entry: spawns the given pre-built command via the runner + /// and wires up termination/progress state. Skips dependency resolution. + func run( + command: PostgresDumpCommand, + database: String, + fileURL: URL, + totalBytesEstimate: Int64? = nil + ) throws { + if case .running = state { throw PostgresDumpError.alreadyRunning } + if case .cancelling = state { throw PostgresDumpError.alreadyRunning } + + let runner = runnerFactory() + try runner.start(command) + self.runner = runner + + state = .running(database: database, fileURL: fileURL, bytesProcessed: 0, totalBytes: totalBytesEstimate) + if kind == .backup { + startByteSizePolling(url: fileURL, database: database, totalBytes: totalBytesEstimate) + } + + Task { @MainActor [weak self] in + guard let result = await self?.runner?.result else { return } + self?.handleTermination(result: result, database: database, fileURL: fileURL) + } + } + + func cancel() { + guard case .running = state else { return } + state = .cancelling + runner?.cancel() + } + + // MARK: - Command Construction + + static func buildCommand( + kind: PostgresDumpKind, + executable: URL, + effective: DatabaseConnection, + database: String, + fileURL: URL, + password: String? + ) -> PostgresDumpCommand { + var args: [String] = ["--no-password"] + args.append(contentsOf: ["-h", effective.host.isEmpty ? "127.0.0.1" : effective.host]) + args.append(contentsOf: ["-p", String(effective.port)]) + if !effective.username.isEmpty { + args.append(contentsOf: ["-U", effective.username]) + } + switch kind { + case .backup: + args.append("-Fc") + args.append(contentsOf: ["-d", database]) + args.append(contentsOf: ["-f", fileURL.path]) + case .restore: + args.append("--no-owner") + args.append("--no-acl") + args.append(contentsOf: ["-d", database]) + args.append(fileURL.path) + } + + var env = ProcessInfo.processInfo.environment + if let password, !password.isEmpty { + env["PGPASSWORD"] = password + } + if effective.sslConfig.isEnabled { + env["PGSSLMODE"] = pgSSLMode(effective.sslConfig.mode) + } + return PostgresDumpCommand( + executable: executable, + arguments: args, + environment: env, + stderrByteCap: 64_000 + ) + } + + static func pgSSLMode(_ mode: SSLMode) -> String { + switch mode { + case .disabled: return "disable" + case .preferred: return "prefer" + case .required: return "require" + case .verifyCa: return "verify-ca" + case .verifyIdentity: return "verify-full" + } + } + + // MARK: - Termination + Progress + + private func handleTermination( + result: PostgresDumpRunResult, + database: String, + fileURL: URL + ) { + byteSizeTask?.cancel() + byteSizeTask = nil + runner = nil + + let writtenBytes: Int64 + if kind == .backup { + writtenBytes = (try? FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as? Int64) ?? 0 + } else { + writtenBytes = 0 + } + + if result.wasCancelled { + if kind == .backup { + try? FileManager.default.removeItem(at: fileURL) + } + state = .cancelled + Self.logger.notice("\(self.kind == .backup ? "pg_dump" : "pg_restore", privacy: .public) cancelled db=\(database, privacy: .public)") + return + } + + if result.exitCode == 0 { + state = .finished(database: database, fileURL: fileURL, bytesProcessed: writtenBytes) + Self.logger.info("\(self.kind == .backup ? "pg_dump" : "pg_restore", privacy: .public) finished bytes=\(writtenBytes) db=\(database, privacy: .public)") + return + } + + if kind == .backup { + try? FileManager.default.removeItem(at: fileURL) + } + let summary = result.stderr.isEmpty + ? String(format: String(localized: "Process exited with code %d"), Int(result.exitCode)) + : result.stderr + state = .failed(message: summary) + Self.logger.error("\(self.kind == .backup ? "pg_dump" : "pg_restore", privacy: .public) failed code=\(result.exitCode) db=\(database, privacy: .public) stderr=\(result.stderr, privacy: .public)") + } + + private func startByteSizePolling(url: URL, database: String, totalBytes: Int64?) { + byteSizeTask?.cancel() + byteSizeTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 250_000_000) + guard let self else { return } + guard case .running = self.state else { return } + let size = (try? FileManager.default + .attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0 + self.state = .running( + database: database, + fileURL: url, + bytesProcessed: size, + totalBytes: totalBytes + ) + } + } + } +} + +// MARK: - Helpers + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} + +// MARK: - Real Process Runner + +/// Concrete `PostgresDumpRunner` that spawns a real subprocess. +/// stderr is accumulated entirely off the main actor inside the +/// `readabilityHandler` closure and the termination handler; +/// only the final string crosses back to MainActor through `result`. +final class ProcessPostgresDumpRunner: PostgresDumpRunner { + private let process = Process() + private let stderrPipe = Pipe() + private let bufferLock = NSLock() + private var stderrBuffer = Data() + private var stderrCap = 64_000 + private var wasCancelled = false + private var continuation: CheckedContinuation? + + func start(_ command: PostgresDumpCommand) throws { + stderrCap = command.stderrByteCap + + process.executableURL = command.executable + process.arguments = command.arguments + process.environment = command.environment + process.standardError = stderrPipe + process.standardOutput = FileHandle.nullDevice + + stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let chunk = handle.availableData + guard !chunk.isEmpty, let self else { return } + self.bufferLock.lock() + self.stderrBuffer.append(chunk) + if self.stderrBuffer.count > self.stderrCap { + self.stderrBuffer = Data(self.stderrBuffer.suffix(self.stderrCap)) + } + self.bufferLock.unlock() + } + + process.terminationHandler = { [weak self] proc in + guard let self else { return } + self.stderrPipe.fileHandleForReading.readabilityHandler = nil + + self.bufferLock.lock() + let stderrText = String(data: self.stderrBuffer, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.bufferLock.unlock() + + let result = PostgresDumpRunResult( + exitCode: proc.terminationStatus, + stderr: stderrText, + wasCancelled: self.wasCancelled + ) + self.continuation?.resume(returning: result) + self.continuation = nil + } + + try process.run() + } + + func cancel() { + wasCancelled = true + if process.isRunning { + process.terminate() + } + } + + var result: PostgresDumpRunResult { + get async { + await withCheckedContinuation { continuation in + if !process.isRunning { + self.bufferLock.lock() + let stderrText = String(data: self.stderrBuffer, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + self.bufferLock.unlock() + continuation.resume(returning: PostgresDumpRunResult( + exitCode: process.terminationStatus, + stderr: stderrText, + wasCancelled: wasCancelled + )) + } else { + self.continuation = continuation + } + } + } + } +} + +// MARK: - Database Size Helper + +extension PostgresDumpService { + /// Best-effort estimate of the database's on-disk size. Used as an upper + /// bound for the backup progress bar; the dump file is typically much + /// smaller because of compression, so the bar tops out at the size and + /// then jumps when pg_dump exits. + /// Returns nil if the query fails or the driver isn't connected. + static func estimatedDatabaseSize( + connection: DatabaseConnection, + database: String + ) async -> Int64? { + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { return nil } + let escaped = database.replacingOccurrences(of: "'", with: "''") + let query = "SELECT pg_database_size('\(escaped)')" + do { + let result = try await driver.execute(query: query) + guard case .text(let value) = result.rows.first?.first else { return nil } + return Int64(value) + } catch { + return nil + } + } +} diff --git a/TablePro/Core/Database/PostgresRestoreService.swift b/TablePro/Core/Database/PostgresRestoreService.swift deleted file mode 100644 index 5bee46adf..000000000 --- a/TablePro/Core/Database/PostgresRestoreService.swift +++ /dev/null @@ -1,195 +0,0 @@ -// -// PostgresRestoreService.swift -// TablePro -// - -import Foundation -import Observation -import os - -@MainActor -@Observable -final class PostgresRestoreService { - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "PostgresRestoreService") - - enum State: Equatable { - case idle - case running(database: String, source: URL) - case cancelling - case finished(database: String, source: URL) - case failed(message: String) - case cancelled - } - - enum RestoreError: LocalizedError { - case pgRestoreNotFound - case unsupportedDatabase - case noSession - case alreadyRunning - case sourceUnreadable - - var errorDescription: String? { - switch self { - case .pgRestoreNotFound: - return String(localized: """ - pg_restore was not found on this system. Install it with `brew install libpq` and \ - link it, or set a custom path under Settings > Terminal > CLI Paths > pg_restore. - """) - case .unsupportedDatabase: - return String(localized: "Restore is only supported for PostgreSQL and Redshift connections.") - case .noSession: - return String(localized: "Connect to the database before starting a restore.") - case .alreadyRunning: - return String(localized: "A restore is already running.") - case .sourceUnreadable: - return String(localized: "The selected backup file is not readable.") - } - } - } - - private(set) var state: State = .idle - - @ObservationIgnored private var process: Process? - @ObservationIgnored private var stderrBuffer = Data() - - /// Returns the resolved pg_restore executable path, honoring the user-configured override. - nonisolated static func resolvePgRestorePath(customPath: String?) -> String? { - CLICommandResolver.findExecutable("pg_restore", customPath: customPath) - } - - private static func pgSSLMode(_ mode: SSLMode) -> String { - switch mode { - case .disabled: return "disable" - case .preferred: return "prefer" - case .required: return "require" - case .verifyCa: return "verify-ca" - case .verifyIdentity: return "verify-full" - } - } - - func start(connection: DatabaseConnection, database: String, source: URL) async throws { - if case .running = state { throw RestoreError.alreadyRunning } - if case .cancelling = state { throw RestoreError.alreadyRunning } - - guard connection.type == .postgresql || connection.type == .redshift else { - throw RestoreError.unsupportedDatabase - } - guard FileManager.default.isReadableFile(atPath: source.path) else { - throw RestoreError.sourceUnreadable - } - - let session = DatabaseManager.shared.session(for: connection.id) - guard session?.isConnected == true else { - throw RestoreError.noSession - } - - let effective = session?.effectiveConnection ?? connection - - let customPath = AppSettingsManager.shared.terminal - .cliPaths[TerminalSettings.pgRestoreCliPathKey]?.nilIfEmpty - guard let pgRestorePath = Self.resolvePgRestorePath(customPath: customPath) else { - throw RestoreError.pgRestoreNotFound - } - - let password = ConnectionStorage.shared.loadPassword(for: connection.id) ?? session?.cachedPassword - - let proc = Process() - proc.executableURL = URL(fileURLWithPath: pgRestorePath) - - var args: [String] = [ - "--no-password", - "--no-owner", - "--no-acl", - "-h", effective.host.isEmpty ? "127.0.0.1" : effective.host, - "-p", String(effective.port), - "-d", database - ] - if !effective.username.isEmpty { - args.append(contentsOf: ["-U", effective.username]) - } - args.append(source.path) - proc.arguments = args - - var env = ProcessInfo.processInfo.environment - if let password, !password.isEmpty { - env["PGPASSWORD"] = password - } - if effective.sslConfig.isEnabled { - env["PGSSLMODE"] = Self.pgSSLMode(effective.sslConfig.mode) - } - proc.environment = env - - let stderrPipe = Pipe() - proc.standardError = stderrPipe - proc.standardOutput = FileHandle.nullDevice - - stderrBuffer = Data() - stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in - let chunk = handle.availableData - guard !chunk.isEmpty else { return } - Task { @MainActor [weak self] in - self?.stderrBuffer.append(chunk) - if self?.stderrBuffer.count ?? 0 > 64_000 { - let trimmed = self?.stderrBuffer.suffix(64_000) ?? Data() - self?.stderrBuffer = Data(trimmed) - } - } - } - - let dbName = database - let sourceURL = source - proc.terminationHandler = { [weak self] terminated in - stderrPipe.fileHandleForReading.readabilityHandler = nil - Task { @MainActor [weak self] in - self?.handleTermination(terminated, database: dbName, source: sourceURL) - } - } - - do { - try proc.run() - } catch { - throw RestoreError.pgRestoreNotFound - } - - self.process = proc - state = .running(database: database, source: source) - - Self.logger.info("pg_restore started pid=\(proc.processIdentifier, privacy: .public) db=\(dbName, privacy: .public)") - } - - func cancel() { - guard case .running = state, let proc = process else { return } - state = .cancelling - proc.terminate() - } - - private func handleTermination(_ proc: Process, database: String, source: URL) { - process = nil - - let exitCode = proc.terminationStatus - - if case .cancelling = state { - state = .cancelled - Self.logger.notice("pg_restore cancelled db=\(database, privacy: .public)") - return - } - - if exitCode == 0 { - state = .finished(database: database, source: source) - Self.logger.info("pg_restore finished db=\(database, privacy: .public)") - return - } - - let stderrText = String(data: stderrBuffer, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let summary = stderrText.isEmpty - ? String(format: String(localized: "pg_restore exited with code %d"), Int(exitCode)) - : stderrText - state = .failed(message: summary) - Self.logger.error("pg_restore failed code=\(exitCode) db=\(database, privacy: .public) stderr=\(stderrText, privacy: .public)") - } -} - -private extension String { - var nilIfEmpty: String? { isEmpty ? nil : self } -} diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 11c532719..a290d48ae 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -7392,12 +7392,6 @@ } } } - }, - "Back Up Database" : { - - }, - "Back Up…" : { - }, "Background" : { "localizations" : { @@ -7421,22 +7415,28 @@ } } }, - "Backing Up Database" : { - "comment" : "A title for a progress sheet that is displayed when backing up a database.", + "Backup Dump" : { + "comment" : "A button that triggers a backup of the current database.", "isCommentAutoGenerated" : true }, - "Backup Cancelled" : { - + "Backup Dump Cancelled" : { + "comment" : "A title for a backup result sheet that was cancelled.", + "isCommentAutoGenerated" : true }, - "Backup Complete" : { + "Backup Dump Complete" : { "comment" : "A title for a backup result sheet when the backup was successful.", "isCommentAutoGenerated" : true }, - "Backup Failed" : { - + "Backup Dump Failed" : { + "comment" : "A title for a backup result sheet when the backup failed.", + "isCommentAutoGenerated" : true }, - "Backup..." : { + "Backup Dump..." : { + }, + "Backup Dump…" : { + "comment" : "A button that triggers a backup dump.", + "isCommentAutoGenerated" : true }, "Backups are only supported for PostgreSQL and Redshift connections." : { "comment" : "Error message displayed when attempting to create a backup of a database that is not PostgreSQL or Redshift.", @@ -8186,12 +8186,13 @@ } } }, - "Cancel Backup" : { + "Cancel Backup Dump" : { "comment" : "A button that cancels a backup.", "isCommentAutoGenerated" : true }, - "Cancel Backup?" : { - + "Cancel Backup Dump?" : { + "comment" : "A confirmation dialog for cancelling a backup.", + "isCommentAutoGenerated" : true }, "Cancel Query" : { "localizations" : { @@ -8259,11 +8260,13 @@ } } }, - "Cancel Restore" : { - + "Cancel Restore Dump" : { + "comment" : "A button that cancels a restore dump.", + "isCommentAutoGenerated" : true }, - "Cancel Restore?" : { - + "Cancel Restore Dump?" : { + "comment" : "A confirmation prompt for cancelling a restore operation.", + "isCommentAutoGenerated" : true }, "Cancelled" : { @@ -13377,6 +13380,10 @@ } } }, + "Creating Backup Dump" : { + "comment" : "A title for a backup progress sheet.", + "isCommentAutoGenerated" : true + }, "Creating..." : { "localizations" : { "tr" : { @@ -38734,17 +38741,27 @@ } } }, - "Restore Cancelled" : { - + "Restore Dump" : { + "comment" : "A button that opens a dialog for restoring a database from a dump.", + "isCommentAutoGenerated" : true }, - "Restore Complete" : { - + "Restore Dump Cancelled" : { + "comment" : "A title for a result sheet that indicates a restore operation was cancelled.", + "isCommentAutoGenerated" : true }, - "Restore Database" : { + "Restore Dump Complete" : { + "comment" : "A title for a backup result sheet that indicates a successful restore.", + "isCommentAutoGenerated" : true + }, + "Restore Dump Failed" : { + "comment" : "A title for a backup result sheet that indicates a failed restore.", + "isCommentAutoGenerated" : true + }, + "Restore Dump..." : { }, - "Restore Failed" : { - "comment" : "A title for a backup failure alert.", + "Restore Dump…" : { + "comment" : "A button that opens a dialog to restore a database from a dump.", "isCommentAutoGenerated" : true }, "Restore from" : { @@ -38775,12 +38792,6 @@ } } } - }, - "Restore..." : { - - }, - "Restore…" : { - }, "Restored “%@” from %@" : { "localizations" : { @@ -38792,8 +38803,8 @@ } } }, - "Restoring Database" : { - "comment" : "A title for a progress sheet that is displayed when restoring a database.", + "Restoring Dump" : { + "comment" : "A title for a progress sheet that is displayed when restoring a database dump.", "isCommentAutoGenerated" : true }, "Restrict to a specific connection (UUID, optional)" : { diff --git a/TablePro/Views/Backup/BackupDatabaseFlow.swift b/TablePro/Views/Backup/BackupDatabaseFlow.swift index bae1d553d..a914c6790 100644 --- a/TablePro/Views/Backup/BackupDatabaseFlow.swift +++ b/TablePro/Views/Backup/BackupDatabaseFlow.swift @@ -2,9 +2,10 @@ // BackupDatabaseFlow.swift // TablePro // -// Top-level sheet for the Backup Database menu item. Reuses +// Top-level sheet for the Backup Dump menu item. Reuses // `DatabaseSwitcherSheet` in `.backup` mode to pick the database, -// then drives a NSSavePanel and the `PostgresBackupService` progress flow. +// then drives an NSSavePanel sub-sheet and the consolidated +// `PostgresDumpService` progress flow. // import AppKit @@ -16,34 +17,28 @@ struct BackupDatabaseFlow: View { let connection: DatabaseConnection let initialDatabase: String - @State private var service = PostgresBackupService() - @State private var phase: Phase + @State private var service = PostgresDumpService(kind: .backup) + @State private var phase: Phase = .pickDatabase private enum Phase: Equatable { case pickDatabase - case running(database: String) + case running(database: String, totalBytes: Int64?) case finished(database: String, destination: URL, bytes: Int64) case failed(message: String) case cancelled } - init(isPresented: Binding, connection: DatabaseConnection, initialDatabase: String) { - self._isPresented = isPresented - self.connection = connection - self.initialDatabase = initialDatabase - self._phase = State(initialValue: .pickDatabase) - } - var body: some View { Group { switch phase { case .pickDatabase: pickerView - case .running(let database): + case .running(let database, let totalBytes): BackupProgressSheet( kind: .backup, database: database, bytesWritten: bytesWritten, + totalBytes: totalBytes, isCancelling: service.state == .cancelling, onCancel: { service.cancel() } ) @@ -89,19 +84,19 @@ struct BackupDatabaseFlow: View { } private var bytesWritten: Int64 { - if case .running(_, let bytes) = service.state { return bytes } + if case .running(_, _, let bytes, _) = service.state { return bytes } return 0 } /// Hashable snapshot of `service.state` so SwiftUI's `onChange` fires on every transition. - private var serviceState: PostgresBackupService.State { service.state } + private var serviceState: PostgresDumpState { service.state } - private func handleServiceStateChange(_ state: PostgresBackupService.State) { + private func handleServiceStateChange(_ state: PostgresDumpState) { switch state { - case .running(let database, _): - phase = .running(database: database) - case .finished(let database, let destination, let bytes): - phase = .finished(database: database, destination: destination, bytes: bytes) + case .running(let database, _, _, let totalBytes): + phase = .running(database: database, totalBytes: totalBytes) + case .finished(let database, let fileURL, let bytes): + phase = .finished(database: database, destination: fileURL, bytes: bytes) case .failed(let message): phase = .failed(message: message) case .cancelled: @@ -117,8 +112,8 @@ struct BackupDatabaseFlow: View { savePanel.showsTagField = false savePanel.allowedContentTypes = [UTType(filenameExtension: "dump") ?? .data] savePanel.nameFieldStringValue = Self.defaultFilename(database: database) - savePanel.title = String(localized: "Save Backup") - savePanel.message = String(format: String(localized: "Choose where to save the backup of \u{201C}%@\u{201D}."), database) + savePanel.title = String(localized: "Save Dump") + savePanel.message = String(format: String(localized: "Choose where to save the dump of \u{201C}%@\u{201D}."), database) let window = NSApp.keyWindow let response: NSApplication.ModalResponse @@ -133,11 +128,22 @@ struct BackupDatabaseFlow: View { return } - // Show the progress sheet immediately so the user sees feedback while - // pg_dump is being located and started. - phase = .running(database: database) + // Show progress immediately so the user gets feedback while we fetch + // the database size estimate and locate pg_dump. + phase = .running(database: database, totalBytes: nil) + + let totalBytes = await PostgresDumpService.estimatedDatabaseSize( + connection: connection, + database: database + ) + do { - try await service.start(connection: connection, database: database, destination: url) + try await service.start( + connection: connection, + database: database, + fileURL: url, + totalBytesEstimate: totalBytes + ) } catch { phase = .failed(message: error.localizedDescription) } diff --git a/TablePro/Views/Backup/BackupProgressSheet.swift b/TablePro/Views/Backup/BackupProgressSheet.swift index fa6d54caf..3c0c42c87 100644 --- a/TablePro/Views/Backup/BackupProgressSheet.swift +++ b/TablePro/Views/Backup/BackupProgressSheet.swift @@ -15,8 +15,13 @@ struct BackupProgressSheet: View { let kind: Kind let database: String - /// Number of bytes written so far. Only shown for `.backup`; ignored for `.restore`. + /// Bytes processed so far. For `.backup` this is the dump file size on disk. let bytesWritten: Int64 + /// Upper bound used to render a determinate bar. For backup this is + /// `pg_database_size`, which over-estimates the dump file (compression), + /// so the bar is capped at ~95% until the process exits. `nil` keeps the + /// bar indeterminate (used for restore). + let totalBytes: Int64? let isCancelling: Bool let onCancel: () -> Void @@ -42,8 +47,7 @@ struct BackupProgressSheet: View { } } - ProgressView() - .progressViewStyle(.linear) + progressBar } HStack(spacing: 8) { @@ -65,13 +69,39 @@ struct BackupProgressSheet: View { .background(Color(nsColor: .windowBackgroundColor)) .interactiveDismissDisabled() .alert(cancelAlertTitle, isPresented: $showCancelConfirmation) { - Button(String(localized: "Continue"), role: .cancel) { } + Button(keepGoingLabel, role: .cancel) { } Button(cancelAlertConfirmLabel, role: .destructive) { onCancel() } } message: { Text(cancelAlertMessage) } } + @ViewBuilder + private var progressBar: some View { + if let totalBytes, totalBytes > 0 { + ProgressView(value: progressFraction) + .progressViewStyle(.linear) + } else { + ProgressView() + .progressViewStyle(.linear) + } + } + + /// Bytes / totalBytes, capped at 0.95 so the bar doesn't appear "done" while + /// pg_dump is still finalizing the archive trailer. + private var progressFraction: Double { + guard let totalBytes, totalBytes > 0 else { return 0 } + let raw = Double(bytesWritten) / Double(totalBytes) + return min(raw, 0.95) + } + + private var keepGoingLabel: String { + switch kind { + case .backup: return String(localized: "Keep Backing Up") + case .restore: return String(localized: "Keep Restoring") + } + } + private var titleString: String { switch kind { case .backup: return String(localized: "Creating Backup Dump") @@ -105,11 +135,23 @@ struct BackupProgressSheet: View { } } -#Preview("Backup") { +#Preview("Backup determinate") { + BackupProgressSheet( + kind: .backup, + database: "production", + bytesWritten: 12_345_678, + totalBytes: 50_000_000, + isCancelling: false, + onCancel: {} + ) +} + +#Preview("Backup indeterminate") { BackupProgressSheet( kind: .backup, database: "production", bytesWritten: 12_345_678, + totalBytes: nil, isCancelling: false, onCancel: {} ) @@ -120,6 +162,7 @@ struct BackupProgressSheet: View { kind: .restore, database: "production", bytesWritten: 0, + totalBytes: nil, isCancelling: false, onCancel: {} ) diff --git a/TablePro/Views/Backup/RestoreDatabaseFlow.swift b/TablePro/Views/Backup/RestoreDatabaseFlow.swift index e80637154..3073f25e1 100644 --- a/TablePro/Views/Backup/RestoreDatabaseFlow.swift +++ b/TablePro/Views/Backup/RestoreDatabaseFlow.swift @@ -2,25 +2,26 @@ // RestoreDatabaseFlow.swift // TablePro // -// Sheet body for the Restore Database menu item. The .dump file is -// chosen up front (NSOpenPanel from MainContentCommandActions), so this -// flow opens directly on `DatabaseSwitcherSheet` in `.restore` mode and -// drives `PostgresRestoreService` through to a result. +// Sheet body for the Restore Dump menu item. Opens NSOpenPanel as a +// sub-sheet on appear (symmetric with backup's NSSavePanel), then +// presents the database picker, then drives `PostgresRestoreService`. // import AppKit import SwiftUI +import UniformTypeIdentifiers struct RestoreDatabaseFlow: View { @Binding var isPresented: Bool let connection: DatabaseConnection let initialDatabase: String - let sourceURL: URL - @State private var service = PostgresRestoreService() - @State private var phase: Phase = .pickDatabase + @State private var service = PostgresDumpService(kind: .restore) + @State private var phase: Phase = .needsSource + @State private var sourceURL: URL? private enum Phase: Equatable { + case needsSource case pickDatabase case running(database: String) case finished(database: String, source: URL) @@ -31,6 +32,9 @@ struct RestoreDatabaseFlow: View { var body: some View { Group { switch phase { + case .needsSource: + // Placeholder while the open panel is presented as a sub-sheet. + sourceLoading case .pickDatabase: pickerView case .running(let database): @@ -38,6 +42,7 @@ struct RestoreDatabaseFlow: View { kind: .restore, database: database, bytesWritten: 0, + totalBytes: nil, isCancelling: service.state == .cancelling, onCancel: { service.cancel() } ) @@ -64,11 +69,28 @@ struct RestoreDatabaseFlow: View { ) } } + .onAppear { + if phase == .needsSource { + Task { await promptForSource() } + } + } .onChange(of: serviceState) { _, newState in handleServiceStateChange(newState) } } + private var sourceLoading: some View { + VStack(spacing: 16) { + ProgressView().controlSize(.regular) + Text("Choose a dump file\u{2026}") + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(40) + .frame(width: 420, height: 200) + .background(Color(nsColor: .windowBackgroundColor)) + } + private var pickerView: some View { VStack(spacing: 0) { sourceBanner @@ -86,35 +108,42 @@ struct RestoreDatabaseFlow: View { } } + @ViewBuilder private var sourceBanner: some View { - HStack(spacing: 8) { - Image(systemName: "doc.zipper") - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - Text("Restore from") - .font(.caption) + if let url = sourceURL { + HStack(spacing: 8) { + Image(systemName: "doc.zipper") .foregroundStyle(.secondary) - Text(sourceURL.lastPathComponent) - .font(.body) - .lineLimit(1) - .truncationMode(.middle) + VStack(alignment: .leading, spacing: 2) { + Text("Restore from") + .font(.caption) + .foregroundStyle(.secondary) + Text(url.lastPathComponent) + .font(.body) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + Button("Change\u{2026}") { + Task { await promptForSource() } + } + .buttonStyle(.link) } - Spacer() + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(width: 420, alignment: .leading) } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(width: 420, alignment: .leading) } /// Hashable snapshot of `service.state` so SwiftUI's `onChange` fires on every transition. - private var serviceState: PostgresRestoreService.State { service.state } + private var serviceState: PostgresDumpState { service.state } - private func handleServiceStateChange(_ state: PostgresRestoreService.State) { + private func handleServiceStateChange(_ state: PostgresDumpState) { switch state { - case .running(let database, _): + case .running(let database, _, _, _): phase = .running(database: database) - case .finished(let database, let source): - phase = .finished(database: database, source: source) + case .finished(let database, let fileURL, _): + phase = .finished(database: database, source: fileURL) case .failed(let message): phase = .failed(message: message) case .cancelled: @@ -124,14 +153,51 @@ struct RestoreDatabaseFlow: View { } } + @MainActor + private func promptForSource() async { + let openPanel = NSOpenPanel() + openPanel.canChooseFiles = true + openPanel.canChooseDirectories = false + openPanel.allowsMultipleSelection = false + openPanel.allowedContentTypes = Self.allowedDumpTypes + openPanel.title = String(localized: "Choose Dump File") + openPanel.prompt = String(localized: "Choose") + openPanel.message = String(localized: "Select a backup file produced by pg_dump in custom archive format (.dump).") + + let window = NSApp.keyWindow + let response: NSApplication.ModalResponse + if let window { + response = await openPanel.beginSheetModal(for: window) + } else { + response = openPanel.runModal() + } + guard response == .OK, let url = openPanel.url else { + // Cancel from the very-first source pick closes the flow; + // cancel from a Change… click leaves the existing source in place. + if sourceURL == nil { isPresented = false } + return + } + sourceURL = url + if phase == .needsSource { phase = .pickDatabase } + } + private func startRestore(database: String) async { - // Show the progress sheet immediately so the user sees feedback while - // pg_restore is being located and started. + guard let source = sourceURL else { return } phase = .running(database: database) do { - try await service.start(connection: connection, database: database, source: sourceURL) + try await service.start(connection: connection, database: database, fileURL: source) } catch { phase = .failed(message: error.localizedDescription) } } + + /// File types accepted in the open panel. `.dump` is the convention for + /// pg_dump custom archive output but plenty of files have generic extensions. + private static var allowedDumpTypes: [UTType] { + var types: [UTType] = [.data] + if let dumpType = UTType(filenameExtension: "dump") { + types.insert(dumpType, at: 0) + } + return types + } } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 9624e7149..c86716096 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -703,24 +703,7 @@ final class MainContentCommandActions { var supportsRestore: Bool { supportsBackup } func restoreDatabase() { - Task { @MainActor in - let openPanel = NSOpenPanel() - openPanel.canChooseFiles = true - openPanel.canChooseDirectories = false - openPanel.allowsMultipleSelection = false - openPanel.title = String(localized: "Choose Backup File") - openPanel.prompt = String(localized: "Choose") - openPanel.message = String(localized: "Select a backup file created with pg_dump custom or directory format.") - - let response: NSApplication.ModalResponse - if let window = self.window { - response = await openPanel.beginSheetModal(for: window) - } else { - response = openPanel.runModal() - } - guard response == .OK, let url = openPanel.url else { return } - coordinator?.activeSheet = .restoreDatabase(fileURL: url) - } + coordinator?.activeSheet = .restoreDatabase } func saveAsFavorite() { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 9075fc7ff..a29e5cb96 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -47,7 +47,7 @@ enum ActiveSheet: Identifiable { case importDialog case exportQueryResults case backupDatabase - case restoreDatabase(fileURL: URL) + case restoreDatabase case maintenance(operation: String, tableName: String) var id: String { @@ -57,7 +57,7 @@ enum ActiveSheet: Identifiable { case .importDialog: "importDialog" case .exportQueryResults: "exportQueryResults" case .backupDatabase: "backupDatabase" - case .restoreDatabase(let fileURL): "restoreDatabase-\(fileURL.path)" + case .restoreDatabase: "restoreDatabase" case .maintenance(let operation, let tableName): "maintenance-\(operation)-\(tableName)" } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index c39e818c3..dfadca13d 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -205,13 +205,12 @@ struct MainContentView: View { initialDatabase: DatabaseManager.shared.session(for: connection.id)?.currentDatabase ?? connection.database ) - case .restoreDatabase(let fileURL): + case .restoreDatabase: RestoreDatabaseFlow( isPresented: dismissBinding, connection: connectionWithCurrentDatabase, initialDatabase: DatabaseManager.shared.session(for: connection.id)?.currentDatabase - ?? connection.database, - sourceURL: fileURL + ?? connection.database ) case .maintenance(let operation, let tableName): MaintenanceSheet( diff --git a/TableProTests/Database/PostgresDumpServiceTests.swift b/TableProTests/Database/PostgresDumpServiceTests.swift new file mode 100644 index 000000000..bf7cb71c0 --- /dev/null +++ b/TableProTests/Database/PostgresDumpServiceTests.swift @@ -0,0 +1,319 @@ +// +// PostgresDumpServiceTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("PostgresDumpService command construction") +struct PostgresDumpServiceCommandTests { + private func connection( + host: String = "db.example.com", + port: Int = 5_432, + username: String = "alice", + sslMode: SSLMode = .disabled + ) -> DatabaseConnection { + var sslConfig = SSLConfiguration() + sslConfig.mode = sslMode + return DatabaseConnection( + name: "Test", + host: host, + port: port, + database: "sales", + username: username, + type: .postgresql, + sshConfig: SSHConfiguration(), + sslConfig: sslConfig + ) + } + + @Test("backup command sets -Fc, host, port, username, -d, -f") + func backupCommandShape() { + let command = PostgresDumpService.buildCommand( + kind: .backup, + executable: URL(fileURLWithPath: "/usr/bin/pg_dump"), + effective: connection(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/sales.dump"), + password: "s3cret" + ) + + #expect(command.arguments.contains("-Fc")) + #expect(command.arguments.contains("--no-password")) + #expect(slice(after: "-h", in: command.arguments) == "db.example.com") + #expect(slice(after: "-p", in: command.arguments) == "5432") + #expect(slice(after: "-U", in: command.arguments) == "alice") + #expect(slice(after: "-d", in: command.arguments) == "sales") + #expect(slice(after: "-f", in: command.arguments) == "/tmp/sales.dump") + #expect(command.environment["PGPASSWORD"] == "s3cret") + } + + @Test("restore command sets --no-owner, --no-acl, -d, positional path") + func restoreCommandShape() { + let command = PostgresDumpService.buildCommand( + kind: .restore, + executable: URL(fileURLWithPath: "/usr/bin/pg_restore"), + effective: connection(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/sales.dump"), + password: "s3cret" + ) + + #expect(command.arguments.contains("--no-owner")) + #expect(command.arguments.contains("--no-acl")) + #expect(command.arguments.contains("--no-password")) + #expect(!command.arguments.contains("-Fc")) + #expect(slice(after: "-d", in: command.arguments) == "sales") + #expect(command.arguments.last == "/tmp/sales.dump") + #expect(!command.arguments.contains("-f")) + } + + @Test("empty host falls back to 127.0.0.1") + func hostFallback() { + let command = PostgresDumpService.buildCommand( + kind: .backup, + executable: URL(fileURLWithPath: "/usr/bin/pg_dump"), + effective: connection(host: ""), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/x.dump"), + password: nil + ) + #expect(slice(after: "-h", in: command.arguments) == "127.0.0.1") + } + + @Test("empty username omits -U entirely") + func usernameOmitted() { + let command = PostgresDumpService.buildCommand( + kind: .backup, + executable: URL(fileURLWithPath: "/usr/bin/pg_dump"), + effective: connection(username: ""), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/x.dump"), + password: nil + ) + #expect(!command.arguments.contains("-U")) + } + + @Test("nil/empty password does not set PGPASSWORD") + func passwordOptional() { + let nilPw = PostgresDumpService.buildCommand( + kind: .backup, + executable: URL(fileURLWithPath: "/usr/bin/pg_dump"), + effective: connection(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/x.dump"), + password: nil + ) + let emptyPw = PostgresDumpService.buildCommand( + kind: .backup, + executable: URL(fileURLWithPath: "/usr/bin/pg_dump"), + effective: connection(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/x.dump"), + password: "" + ) + #expect(nilPw.environment["PGPASSWORD"] == nil) + #expect(emptyPw.environment["PGPASSWORD"] == nil) + } + + @Test("SSL mode maps to libpq PGSSLMODE values", arguments: [ + (SSLMode.disabled, nil as String?), + (SSLMode.preferred, "prefer"), + (SSLMode.required, "require"), + (SSLMode.verifyCa, "verify-ca"), + (SSLMode.verifyIdentity, "verify-full") + ]) + func sslModeMapping(mode: SSLMode, expected: String?) { + let command = PostgresDumpService.buildCommand( + kind: .backup, + executable: URL(fileURLWithPath: "/usr/bin/pg_dump"), + effective: connection(sslMode: mode), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/x.dump"), + password: nil + ) + #expect(command.environment["PGSSLMODE"] == expected) + } + + /// Returns the argument immediately following `flag` in the arg list. + private func slice(after flag: String, in args: [String]) -> String? { + guard let index = args.firstIndex(of: flag), index + 1 < args.count else { return nil } + return args[index + 1] + } +} + +// MARK: - Fake Runner + +/// Test double for `PostgresDumpRunner` that lets tests drive the result. +private final class FakeDumpRunner: PostgresDumpRunner, @unchecked Sendable { + private(set) var startedCommand: PostgresDumpCommand? + private(set) var cancelCount: Int = 0 + private var continuation: CheckedContinuation? + private let lock = NSLock() + + func start(_ command: PostgresDumpCommand) throws { + startedCommand = command + } + + func cancel() { + lock.lock() + cancelCount += 1 + lock.unlock() + } + + var result: PostgresDumpRunResult { + get async { + await withCheckedContinuation { continuation in + self.lock.lock() + self.continuation = continuation + self.lock.unlock() + } + } + } + + /// Test driver: resolves the pending `result` await with the given outcome. + func finish(_ outcome: PostgresDumpRunResult) { + lock.lock() + let continuation = self.continuation + self.continuation = nil + lock.unlock() + continuation?.resume(returning: outcome) + } +} + +@Suite("PostgresDumpService state machine", .serialized) +@MainActor +struct PostgresDumpServiceStateMachineTests { + private func fakeCommand() -> PostgresDumpCommand { + PostgresDumpCommand( + executable: URL(fileURLWithPath: "/usr/bin/true"), + arguments: [], + environment: [:], + stderrByteCap: 64_000 + ) + } + + @Test("successful run transitions idle -> running -> finished") + func successfulBackup() async throws { + let runner = FakeDumpRunner() + let service = PostgresDumpService(kind: .backup, runnerFactory: { runner }) + + #expect(service.state == .idle) + try service.run( + command: fakeCommand(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/test-success.dump"), + totalBytesEstimate: 1_000 + ) + + // Now running + if case .running(let db, _, _, let total) = service.state { + #expect(db == "sales") + #expect(total == 1_000) + } else { + Issue.record("expected running, got \(service.state)") + } + + runner.finish(.init(exitCode: 0, stderr: "", wasCancelled: false)) + try await waitFor { if case .finished = service.state { return true }; return false } + + if case .finished(let db, _, _) = service.state { + #expect(db == "sales") + } else { + Issue.record("expected finished, got \(service.state)") + } + } + + @Test("non-zero exit transitions to failed and surfaces stderr") + func failedRun() async throws { + let runner = FakeDumpRunner() + let service = PostgresDumpService(kind: .restore, runnerFactory: { runner }) + + try service.run( + command: fakeCommand(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/test-failed.dump") + ) + + runner.finish(.init(exitCode: 1, stderr: "FATAL: connection refused", wasCancelled: false)) + try await waitFor { if case .failed = service.state { return true }; return false } + + if case .failed(let message) = service.state { + #expect(message == "FATAL: connection refused") + } else { + Issue.record("expected failed, got \(service.state)") + } + } + + @Test("cancel transitions running -> cancelling -> cancelled") + func cancelRun() async throws { + let runner = FakeDumpRunner() + let service = PostgresDumpService(kind: .backup, runnerFactory: { runner }) + + try service.run( + command: fakeCommand(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/test-cancel.dump") + ) + + service.cancel() + #expect(service.state == .cancelling) + #expect(runner.cancelCount == 1) + + runner.finish(.init(exitCode: -15, stderr: "", wasCancelled: true)) + try await waitFor { service.state == .cancelled } + #expect(service.state == .cancelled) + } + + @Test("calling run while already running throws alreadyRunning") + func doubleRunThrows() throws { + let runner = FakeDumpRunner() + let service = PostgresDumpService(kind: .backup, runnerFactory: { runner }) + + try service.run( + command: fakeCommand(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/test-double.dump") + ) + + #expect(throws: PostgresDumpError.alreadyRunning) { + try service.run( + command: fakeCommand(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/test-double-2.dump") + ) + } + } + + @Test("empty stderr falls back to a synthesized error message") + func emptyStderrFallback() async throws { + let runner = FakeDumpRunner() + let service = PostgresDumpService(kind: .backup, runnerFactory: { runner }) + + try service.run( + command: fakeCommand(), + database: "sales", + fileURL: URL(fileURLWithPath: "/tmp/test-emptyerr.dump") + ) + runner.finish(.init(exitCode: 42, stderr: "", wasCancelled: false)) + try await waitFor { if case .failed = service.state { return true }; return false } + + if case .failed(let message) = service.state { + #expect(message.contains("42")) + } else { + Issue.record("expected failed, got \(service.state)") + } + } + + /// Polls `condition` every 10ms up to 2 seconds. + private func waitFor(_ condition: @MainActor @Sendable () -> Bool) async throws { + for _ in 0..<200 { + if condition() { return } + try await Task.sleep(nanoseconds: 10_000_000) + } + Issue.record("timed out waiting for condition") + } +} From d14fc8117628a0ff1ff53c5fa4d54ed39dd86132 Mon Sep 17 00:00:00 2001 From: Samir Mammadhasanov Date: Mon, 11 May 2026 13:45:53 +0400 Subject: [PATCH 09/11] fix(backup): split guard case binding so it parses on Swift 5.9 --- TablePro/Core/Database/PostgresDumpService.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Database/PostgresDumpService.swift b/TablePro/Core/Database/PostgresDumpService.swift index 267d734db..c8a2fd198 100644 --- a/TablePro/Core/Database/PostgresDumpService.swift +++ b/TablePro/Core/Database/PostgresDumpService.swift @@ -432,7 +432,8 @@ extension PostgresDumpService { let query = "SELECT pg_database_size('\(escaped)')" do { let result = try await driver.execute(query: query) - guard case .text(let value) = result.rows.first?.first else { return nil } + guard let firstCell = result.rows.first?.first, + case .text(let value) = firstCell else { return nil } return Int64(value) } catch { return nil From 7b4d9bd68a1bb5ce6f8a9adc5f16e7d408a0b90a Mon Sep 17 00:00:00 2001 From: Samir Mammadhasanov Date: Mon, 11 May 2026 13:48:04 +0400 Subject: [PATCH 10/11] fix(backup): rewrite pg_database_size cell extraction as switch --- TablePro/Core/Database/PostgresDumpService.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/Database/PostgresDumpService.swift b/TablePro/Core/Database/PostgresDumpService.swift index c8a2fd198..5ec8a036e 100644 --- a/TablePro/Core/Database/PostgresDumpService.swift +++ b/TablePro/Core/Database/PostgresDumpService.swift @@ -432,9 +432,12 @@ extension PostgresDumpService { let query = "SELECT pg_database_size('\(escaped)')" do { let result = try await driver.execute(query: query) - guard let firstCell = result.rows.first?.first, - case .text(let value) = firstCell else { return nil } - return Int64(value) + switch result.rows.first?.first { + case .text(let value): + return Int64(value) + default: + return nil + } } catch { return nil } From d9a9894e43dd54312238bddf232e8dc55bac7291 Mon Sep 17 00:00:00 2001 From: Samir Mammadhasanov Date: Mon, 11 May 2026 14:05:09 +0400 Subject: [PATCH 11/11] fix(backup): use asText helper for pg_database_size cell extraction --- TablePro/Core/Database/PostgresDumpService.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/TablePro/Core/Database/PostgresDumpService.swift b/TablePro/Core/Database/PostgresDumpService.swift index 5ec8a036e..e9a225d4a 100644 --- a/TablePro/Core/Database/PostgresDumpService.swift +++ b/TablePro/Core/Database/PostgresDumpService.swift @@ -10,6 +10,7 @@ import Foundation import Observation import os +import TableProPluginKit // MARK: - Public Types @@ -432,12 +433,8 @@ extension PostgresDumpService { let query = "SELECT pg_database_size('\(escaped)')" do { let result = try await driver.execute(query: query) - switch result.rows.first?.first { - case .text(let value): - return Int64(value) - default: - return nil - } + guard let text = result.rows.first?.first?.asText else { return nil } + return Int64(text) } catch { return nil }