Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
cc1cb34
fix(plugin-postgresql): close continuation race in dump runner
datlechin May 13, 2026
9bed148
fix(changelog): move backup entries to Unreleased and match menu naming
datlechin May 13, 2026
c3a2159
refactor(plugin-postgresql): pass a minimal env to pg_dump and pg_res…
datlechin May 13, 2026
6134c05
refactor(plugin-postgresql): drop duplicate stderr cap field on runner
datlechin May 13, 2026
478ac71
refactor(coordinator): pick restore source before opening the restore…
datlechin May 13, 2026
354fb24
fix(coordinator): make backup/restore failure detail scrollable and m…
datlechin May 13, 2026
c249c9d
refactor(plugin-postgresql): parameterize pg_database_size estimate q…
datlechin May 13, 2026
3596d85
refactor(coordinator): rename DatabaseSwitcherSheet.Mode.switch to .s…
datlechin May 13, 2026
10fb499
test(plugin-postgresql): await dump-service state stream instead of s…
datlechin May 13, 2026
d77ca9b
fix(plugin-postgresql): mark dump command builders nonisolated
datlechin May 13, 2026
3c48e91
fix(plugin-postgresql): make dump state-stream eager and fake runner …
datlechin May 13, 2026
99e8660
style(datagrid): sort imports in DataGridRowViewSetValueTests
datlechin May 13, 2026
034c33f
chore(plugin-postgresql): regenerate xcstrings for new dump error mes…
datlechin May 13, 2026
216f585
docs: correct database count, MSSQL distribution, BigQuery card, em dash
datlechin May 13, 2026
beea2a9
docs(plugin-postgresql): add Backup and Restore page
datlechin May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 4 additions & 16 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- File > Backup Dump… and Restore Dump… for PostgreSQL and Redshift connections, running `pg_dump -Fc` and `pg_restore --no-owner --no-acl` with live progress, cancel, SSH tunnel reuse, and custom binary paths under Settings > Terminal > CLI Paths (#1211).

## [0.40.3] - 2026-05-13

### Fixed
Expand Down Expand Up @@ -66,22 +70,6 @@ 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
- iOS: VoiceOver "Delete row" / "Delete group" / "Delete tag" custom actions on rows whose only deletion path was a swipe gesture
- iOS: empty Groups and Tags screens show a Create button so the action is reachable without opening the toolbar
- iOS: "No Results" empty state in Query Editor explains the query returned no rows
- iOS: iCloud sync runs every 30 minutes in the background via `BGAppRefreshTask` while the app is closed (gated by the iCloud Sync setting); iOS schedules the actual cadence based on usage and battery
- iOS: Cmd+F focuses the search field in Tables and Data Browser (iPad keyboard canonical)
- iOS: search text in Tables and Data Browser persists across process kill via `@SceneStorage` (per-window on iPad)
- iOS Settings: iCloud Sync toggle (off keeps connections, groups, and tags on this device only and disables the sync toolbar button), Rows per Page picker (50/100/200/500, applied to new data browser sessions), Default Safe Mode picker (applied when adding a new connection), "Hide query in Live Activities" toggle that swaps the SQL preview for a generic "Running query" label on the lock screen and Dynamic Island
- iOS: alert when the active connection is deleted mid-session (for example via iCloud sync from another device), so a stale screen no longer fails silently on the next action
- iOS: Face ID, Touch ID, or Optic ID lock with cold-launch protection and idle timeout (1, 5, 15, or 60 minutes), opt-in from Settings
- iOS: Connection Info tab replaces the per-connection Settings tab, showing host, SSL, SSH tunnel, active database, and live connection status
- MCP Setup sheet adds Zed alongside Claude Desktop, Claude Code, and Cursor with a one-paste `context_servers` snippet
- Vim mode in the SQL editor (motions, operators, text objects, registers, macros, marks, search)
- Sidebar groups views, materialized views, foreign tables, procedures, and functions; Show DDL opens in a new tab (#1038)
- iOS: Live Activity for running queries
Expand Down
116 changes: 73 additions & 43 deletions TablePro/Core/Database/PostgresDumpService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,27 @@ final class PostgresDumpService {
@ObservationIgnored private let runnerFactory: () -> any PostgresDumpRunner
@ObservationIgnored private var runner: (any PostgresDumpRunner)?
@ObservationIgnored private var byteSizeTask: Task<Void, Never>?
@ObservationIgnored private var stateObservers: [UUID: AsyncStream<PostgresDumpState>.Continuation] = [:]

func stateUpdates() -> AsyncStream<PostgresDumpState> {
let (stream, continuation) = AsyncStream<PostgresDumpState>.makeStream()
let id = UUID()
stateObservers[id] = continuation
continuation.yield(state)
continuation.onTermination = { @Sendable [weak self] _ in
Task { @MainActor in
self?.stateObservers.removeValue(forKey: id)
}
}
return stream
}

private func setState(_ newState: PostgresDumpState) {
state = newState
for continuation in stateObservers.values {
continuation.yield(newState)
}
}

/// Default initializer uses the real `Process`-backed runner.
init(kind: PostgresDumpKind) {
Expand Down Expand Up @@ -193,7 +214,7 @@ final class PostgresDumpService {
try runner.start(command)
self.runner = runner

state = .running(database: database, fileURL: fileURL, bytesProcessed: 0, totalBytes: totalBytesEstimate)
setState(.running(database: database, fileURL: fileURL, bytesProcessed: 0, totalBytes: totalBytesEstimate))
if kind == .backup {
startByteSizePolling(url: fileURL, database: database, totalBytes: totalBytesEstimate)
}
Expand All @@ -206,13 +227,13 @@ final class PostgresDumpService {

func cancel() {
guard case .running = state else { return }
state = .cancelling
setState(.cancelling)
runner?.cancel()
}

// MARK: - Command Construction

static func buildCommand(
nonisolated static func buildCommand(
kind: PostgresDumpKind,
executable: URL,
effective: DatabaseConnection,
Expand All @@ -238,12 +259,12 @@ final class PostgresDumpService {
args.append(fileURL.path)
}

var env = ProcessInfo.processInfo.environment
var env = minimalEnvironment()
if let password, !password.isEmpty {
env["PGPASSWORD"] = password
}
if effective.sslConfig.isEnabled {
env["PGSSLMODE"] = pgSSLMode(effective.sslConfig.mode)
if effective.sslConfig.isEnabled, let sslMode = pgSSLMode(effective.sslConfig.mode) {
env["PGSSLMODE"] = sslMode
}
return PostgresDumpCommand(
executable: executable,
Expand All @@ -253,9 +274,22 @@ final class PostgresDumpService {
)
}

static func pgSSLMode(_ mode: SSLMode) -> String {
nonisolated private static let inheritedEnvironmentKeys: [String] = [
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL"
]

nonisolated static func minimalEnvironment() -> [String: String] {
let parent = ProcessInfo.processInfo.environment
var env: [String: String] = [:]
for key in inheritedEnvironmentKeys where parent[key] != nil {
env[key] = parent[key]
}
return env
}

nonisolated static func pgSSLMode(_ mode: SSLMode) -> String? {
switch mode {
case .disabled: return "disable"
case .disabled: return nil
case .preferred: return "prefer"
case .required: return "require"
case .verifyCa: return "verify-ca"
Expand Down Expand Up @@ -285,13 +319,13 @@ final class PostgresDumpService {
if kind == .backup {
try? FileManager.default.removeItem(at: fileURL)
}
state = .cancelled
setState(.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)
setState(.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
}
Expand All @@ -302,7 +336,7 @@ final class PostgresDumpService {
let summary = result.stderr.isEmpty
? String(format: String(localized: "Process exited with code %d"), Int(result.exitCode))
: result.stderr
state = .failed(message: summary)
setState(.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)")
}

Expand All @@ -315,12 +349,12 @@ final class PostgresDumpService {
guard case .running = self.state else { return }
let size = (try? FileManager.default
.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
self.state = .running(
self.setState(.running(
database: database,
fileURL: url,
bytesProcessed: size,
totalBytes: totalBytes
)
))
}
}
}
Expand All @@ -334,21 +368,17 @@ private extension String {

// 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 let stateLock = NSLock()
private var stderrBuffer = Data()
private var stderrCap = 64_000
private var wasCancelled = false
private var terminationResult: PostgresDumpRunResult?
private var continuation: CheckedContinuation<PostgresDumpRunResult, Never>?

func start(_ command: PostgresDumpCommand) throws {
stderrCap = command.stderrByteCap
let stderrCap = command.stderrByteCap

process.executableURL = command.executable
process.arguments = command.arguments
Expand All @@ -359,37 +389,41 @@ final class ProcessPostgresDumpRunner: PostgresDumpRunner {
stderrPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let chunk = handle.availableData
guard !chunk.isEmpty, let self else { return }
self.bufferLock.lock()
self.stateLock.lock()
self.stderrBuffer.append(chunk)
if self.stderrBuffer.count > self.stderrCap {
self.stderrBuffer = Data(self.stderrBuffer.suffix(self.stderrCap))
if self.stderrBuffer.count > stderrCap {
self.stderrBuffer = Data(self.stderrBuffer.suffix(stderrCap))
}
self.bufferLock.unlock()
self.stateLock.unlock()
}

process.terminationHandler = { [weak self] proc in
guard let self else { return }
self.stderrPipe.fileHandleForReading.readabilityHandler = nil

self.bufferLock.lock()
self.stateLock.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.terminationResult = result
let pending = self.continuation
self.continuation = nil
self.stateLock.unlock()

pending?.resume(returning: result)
}

try process.run()
}

func cancel() {
stateLock.lock()
wasCancelled = true
stateLock.unlock()
if process.isRunning {
process.terminate()
}
Expand All @@ -398,19 +432,14 @@ final class ProcessPostgresDumpRunner: PostgresDumpRunner {
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
stateLock.lock()
if let cached = terminationResult {
stateLock.unlock()
continuation.resume(returning: cached)
return
}
self.continuation = continuation
stateLock.unlock()
}
}
}
Expand All @@ -429,10 +458,11 @@ extension PostgresDumpService {
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)
let result = try await driver.executeParameterized(
query: "SELECT pg_database_size($1)",
parameters: [database]
)
guard let text = result.rows.first?.first?.asText else { return nil }
return Int64(text)
} catch {
Expand Down
Loading
Loading