Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- MongoDB Atlas connections failing to authenticate (#438)
- MongoDB TLS certificate verification skipped for SRV connections
Comment thread
datlechin marked this conversation as resolved.

## [0.23.1] - 2026-03-24

### Added
Expand Down
5 changes: 3 additions & 2 deletions Plugins/MongoDBDriverPlugin/MongoDBConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ final class MongoDBConnection: @unchecked Sendable {
let effectiveAuthSource: String
if let source = authSource, !source.isEmpty {
effectiveAuthSource = source
} else if useSrv {
effectiveAuthSource = "admin"
} else if !database.isEmpty {
effectiveAuthSource = database
} else {
Expand All @@ -206,8 +208,7 @@ final class MongoDBConnection: @unchecked Sendable {
let sslEnabled = ["Preferred", "Required", "Verify CA", "Verify Identity"].contains(sslMode)
if sslEnabled {
params.append("tls=true")
let verifiesCert = sslMode == "Verify CA" || sslMode == "Verify Identity"
if !verifiesCert {
if sslMode == "Preferred" {
params.append("tlsAllowInvalidCertificates=true")
}
Comment thread
datlechin marked this conversation as resolved.
if !sslCACertPath.isEmpty {
Expand Down
29 changes: 13 additions & 16 deletions TablePro/AppDelegate+WindowConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,18 +100,22 @@ extension AppDelegate {

// MARK: - Window Identification

private enum WindowId {
static let main = "main"
static let welcome = "welcome"
static let connectionForm = "connection-form"
}

func isMainWindow(_ window: NSWindow) -> Bool {
guard let identifier = window.identifier?.rawValue else { return false }
return identifier.contains("main")
window.identifier?.rawValue == WindowId.main
}

func isWelcomeWindow(_ window: NSWindow) -> Bool {
window.identifier?.rawValue == "welcome" ||
window.title.lowercased().contains("welcome")
window.identifier?.rawValue == WindowId.welcome
}

private func isConnectionFormWindow(_ window: NSWindow) -> Bool {
window.identifier?.rawValue.contains("connection-form") == true
window.identifier?.rawValue == WindowId.connectionForm
}

// MARK: - Welcome Window
Expand Down Expand Up @@ -259,10 +263,7 @@ extension AppDelegate {

if remainingMainWindows == 0 {
NotificationCenter.default.post(name: .mainWindowWillClose, object: nil)

DispatchQueue.main.async {
self.openWelcomeWindow()
}
openWelcomeWindow()
}
}
}
Expand All @@ -273,13 +274,9 @@ extension AppDelegate {

if isWelcomeWindow(window),
window.occlusionState.contains(.visible),
NSApp.windows.contains(where: { isMainWindow($0) && $0.isVisible }) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if self.isWelcomeWindow(window), window.isVisible {
window.close()
}
}
NSApp.windows.contains(where: { isMainWindow($0) && $0.isVisible }),
window.isVisible {
window.close()
}
}

Expand Down
12 changes: 10 additions & 2 deletions TablePro/Core/ChangeTracking/DataChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ final class DataChangeManager {

// MARK: - Configuration

/// Clear all changes (called after successful save)
/// Clear all tracked changes, preserving undo/redo history.
/// Use when changes are invalidated but undo context may still be relevant.
func clearChanges() {
changes.removeAll()
changeIndex.removeAll()
Expand All @@ -134,11 +135,18 @@ final class DataChangeManager {
modifiedCells.removeAll()
insertedRowData.removeAll()
changedRowIndices.removeAll()
undoManager.clearAll()
hasChanges = false
reloadVersion += 1
}

/// Clear all tracked changes AND undo/redo history.
/// Use after successful save, explicit discard, or new query execution
/// where undo context is no longer meaningful.
func clearChangesAndUndoHistory() {
clearChanges()
undoManager.clearAll()
}

/// Atomically configure the manager for a new table
func configureForTable(
tableName: String,
Expand Down
11 changes: 1 addition & 10 deletions TablePro/Core/Database/ConnectionHealthMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ actor ConnectionHealthMonitor {
// MARK: - Configuration

private static let pingInterval: TimeInterval = 30.0
private static let initialBackoffDelays: [TimeInterval] = [2.0, 4.0, 8.0]
private static let maxBackoffDelay: TimeInterval = 120.0

// MARK: - Dependencies
Expand Down Expand Up @@ -227,15 +226,7 @@ actor ConnectionHealthMonitor {
/// Uses the initial delay table for the first few attempts, then doubles
/// the previous delay for subsequent attempts, capped at `maxBackoffDelay`.
private func backoffDelay(for attempt: Int) -> TimeInterval {
let delays = Self.initialBackoffDelays
if attempt <= delays.count {
return delays[attempt - 1]
}
// Exponential: last seed delay * 2^(attempt - seedCount)
let lastSeed = delays[delays.count - 1]
let exponent = attempt - delays.count
let computed = lastSeed * pow(2.0, Double(exponent))
return min(computed, Self.maxBackoffDelay)
ExponentialBackoff.delay(for: attempt, maxDelay: Self.maxBackoffDelay)
}

// MARK: - State Transitions
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -315,12 +315,15 @@ extension DatabaseDriver {
/// Factory for creating database drivers via plugin lookup
@MainActor
enum DatabaseDriverFactory {
private static let logger = Logger(subsystem: "com.TablePro", category: "DatabaseDriverFactory")

static func createDriver(for connection: DatabaseConnection) throws -> DatabaseDriver {
let pluginId = connection.type.pluginTypeId
// If the plugin isn't registered yet and background loading hasn't finished,
// fall back to synchronous loading for this critical code path.
if PluginManager.shared.driverPlugins[pluginId] == nil,
!PluginManager.shared.hasFinishedInitialLoad {
logger.warning("Plugin '\(pluginId)' not loaded yet — performing synchronous load")
PluginManager.shared.loadPendingPlugins()
}
guard let plugin = PluginManager.shared.driverPlugins[pluginId] else {
Expand Down
91 changes: 73 additions & 18 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ final class DatabaseManager {
/// The health monitor skips pings while a query is running to avoid
/// racing on non-thread-safe driver connections.
@ObservationIgnored private var queriesInFlight: [UUID: Int] = [:]
/// Tracks when the first query started for each session (used for staleness detection).
@ObservationIgnored private var queryStartTimes: [UUID: Date] = [:]

/// Current session (computed from currentSessionId)
var currentSession: ConnectionSession? {
Expand Down Expand Up @@ -130,7 +132,11 @@ final class DatabaseManager {
// Close tunnel if SSH was established
if connection.sshConfig.enabled {
Task {
try? await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
do {
try await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
} catch {
Self.logger.warning("SSH tunnel cleanup failed for \(connection.name): \(error.localizedDescription)")
}
}
}
removeSessionEntry(for: connection.id)
Expand Down Expand Up @@ -220,7 +226,11 @@ final class DatabaseManager {
// Close tunnel if connection failed
if connection.sshConfig.enabled {
Task {
try? await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
do {
try await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
} catch {
Self.logger.warning("SSH tunnel cleanup failed for \(connection.name): \(error.localizedDescription)")
}
}
}

Expand Down Expand Up @@ -256,7 +266,11 @@ final class DatabaseManager {

// Close SSH tunnel if exists
if session.connection.sshConfig.enabled {
try? await SSHTunnelManager.shared.closeTunnel(connectionId: session.connection.id)
do {
try await SSHTunnelManager.shared.closeTunnel(connectionId: session.connection.id)
} catch {
Self.logger.warning("SSH tunnel cleanup failed for \(session.connection.name): \(error.localizedDescription)")
}
}

// Stop health monitoring
Expand Down Expand Up @@ -343,11 +357,15 @@ final class DatabaseManager {
operation: () async throws -> T
) async throws -> T {
queriesInFlight[sessionId, default: 0] += 1
if queriesInFlight[sessionId] == 1 {
queryStartTimes[sessionId] = Date()
}
defer {
if let count = queriesInFlight[sessionId], count > 1 {
queriesInFlight[sessionId] = count - 1
} else {
queriesInFlight.removeValue(forKey: sessionId)
queryStartTimes.removeValue(forKey: sessionId)
}
}
return try await operation()
Expand Down Expand Up @@ -402,13 +420,21 @@ final class DatabaseManager {
result = try await driver.testConnection()
} catch {
if connection.sshConfig.enabled {
try? await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
do {
try await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
} catch {
Self.logger.warning("SSH tunnel cleanup failed for \(connection.name): \(error.localizedDescription)")
}
}
throw error
}

if connection.sshConfig.enabled {
try? await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
do {
try await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
} catch {
Self.logger.warning("SSH tunnel cleanup failed for \(connection.name): \(error.localizedDescription)")
}
}

return result
Expand Down Expand Up @@ -532,7 +558,16 @@ final class DatabaseManager {
guard let self else { return false }
// Skip ping while a user query is in-flight to avoid racing
// on the same non-thread-safe driver connection.
guard await self.queriesInFlight[connectionId] == nil else { return true }
// Allow ping if the query appears stuck (exceeds timeout + grace period).
if await self.queriesInFlight[connectionId] != nil {
let queryTimeout = await TimeInterval(AppSettingsManager.shared.general.queryTimeoutSeconds)
let maxStale = max(queryTimeout, 300) // At least 5 minutes
if let startTime = await self.queryStartTimes[connectionId],
Date().timeIntervalSince(startTime) < maxStale {
return true // Query still within expected time
}
// Query appears stuck — fall through to ping
}
guard let mainDriver = await self.activeSessions[connectionId]?.driver else {
return false
}
Expand All @@ -547,12 +582,13 @@ final class DatabaseManager {
guard let self else { return false }
guard let session = await self.activeSessions[connectionId] else { return false }
do {
let driver = try await self.reconnectDriver(for: session)
let driver = try await self.trackOperation(sessionId: connectionId) {
try await self.reconnectDriver(for: session)
}
await self.updateSession(connectionId) { session in
session.driver = driver
session.status = .connected
}

return true
} catch {
return false
Expand Down Expand Up @@ -619,13 +655,21 @@ final class DatabaseManager {

if let savedSchema = session.currentSchema,
let schemaDriver = driver as? SchemaSwitchable {
try? await schemaDriver.switchSchema(to: savedSchema)
do {
try await schemaDriver.switchSchema(to: savedSchema)
} catch {
Self.logger.warning("Failed to restore schema '\(savedSchema)' on reconnect: \(error.localizedDescription)")
}
}

// Restore database for MSSQL if session had a non-default database
if let savedDatabase = session.currentDatabase,
let adapter = driver as? PluginDriverAdapter {
try? await adapter.switchDatabase(to: savedDatabase)
do {
try await adapter.switchDatabase(to: savedDatabase)
} catch {
Self.logger.warning("Failed to restore database '\(savedDatabase)' on reconnect: \(error.localizedDescription)")
}
}

return driver
Expand Down Expand Up @@ -659,8 +703,8 @@ final class DatabaseManager {
await stopHealthMonitor(for: sessionId)

do {
// Disconnect existing drivers
session.driver?.disconnect()
// Disconnect existing driver (re-fetch to avoid stale local reference)
activeSessions[sessionId]?.driver?.disconnect()

// Recreate SSH tunnel if needed and build effective connection
let effectiveConnection = try await buildEffectiveConnection(for: session.connection)
Expand All @@ -681,13 +725,21 @@ final class DatabaseManager {

if let savedSchema = activeSessions[sessionId]?.currentSchema,
let schemaDriver = driver as? SchemaSwitchable {
try? await schemaDriver.switchSchema(to: savedSchema)
do {
try await schemaDriver.switchSchema(to: savedSchema)
} catch {
Self.logger.warning("Failed to restore schema '\(savedSchema)' on reconnect: \(error.localizedDescription)")
}
}

// Restore database for MSSQL if session had a non-default database
if let savedDatabase = activeSessions[sessionId]?.currentDatabase,
let adapter = driver as? PluginDriverAdapter {
try? await adapter.switchDatabase(to: savedDatabase)
do {
try await adapter.switchDatabase(to: savedDatabase)
} catch {
Self.logger.warning("Failed to restore database '\(savedDatabase)' on reconnect: \(error.localizedDescription)")
}
}

// Update session
Expand Down Expand Up @@ -741,8 +793,7 @@ final class DatabaseManager {

let maxRetries = 5
for retryCount in 0..<maxRetries {
// Exponential backoff: 2s, 4s, 8s, 16s, 32s (capped at 60s)
let delay = min(60.0, 2.0 * pow(2.0, Double(retryCount)))
let delay = ExponentialBackoff.delay(for: retryCount + 1, maxDelay: 60)
Self.logger.info("SSH reconnect attempt \(retryCount + 1)/\(maxRetries) in \(delay)s for: \(session.connection.name)")
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))

Expand All @@ -768,18 +819,20 @@ final class DatabaseManager {

nonisolated private static let startupLogger = Logger(subsystem: "com.TablePro", category: "DatabaseManager")

@discardableResult
nonisolated private func executeStartupCommands(
_ commands: String?, on driver: DatabaseDriver, connectionName: String
) async {
) async -> [(statement: String, error: String)] {
guard let commands, !commands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return
return []
}

let statements = commands
.components(separatedBy: CharacterSet(charactersIn: ";\n"))
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }

var failures: [(statement: String, error: String)] = []
for statement in statements {
do {
_ = try await driver.execute(query: statement)
Expand All @@ -790,8 +843,10 @@ final class DatabaseManager {
Self.startupLogger.warning(
"Startup command failed for '\(connectionName)': \(statement) — \(error.localizedDescription)"
)
failures.append((statement: statement, error: error.localizedDescription))
}
}
return failures
}

// MARK: - Schema Changes
Expand Down
Loading
Loading