Skip to content

Commit 8e4658f

Browse files
committed
refactor: split DatabaseManager, TableStructureView, PluginManager into extensions
DatabaseManager.swift (1043 → 83 lines): - +Sessions (328), +Queries (109), +SSH (148), +Health (266), +Startup (46), +Schema (137) TableStructureView.swift (1074 → 279 lines): - +Editing (342), +ContextMenu (151), +Schema (203), +DataLoading (159) PluginManager.swift (1209 → 517 lines): - +Registration (451), +Lifecycle (196), +Validation (81) 13 new extension files. No API changes.
1 parent 330a37a commit 8e4658f

16 files changed

Lines changed: 2666 additions & 2496 deletions
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
//
2+
// DatabaseManager+Health.swift
3+
// TablePro
4+
//
5+
// Created by Ngo Quoc Dat on 16/12/25.
6+
//
7+
8+
import AppKit
9+
import Foundation
10+
import os
11+
import TableProPluginKit
12+
13+
// MARK: - Health Monitoring
14+
15+
extension DatabaseManager {
16+
/// Start health monitoring for a connection
17+
internal func startHealthMonitor(for connectionId: UUID) async {
18+
// Stop any existing monitor
19+
await stopHealthMonitor(for: connectionId)
20+
21+
let monitor = ConnectionHealthMonitor(
22+
connectionId: connectionId,
23+
pingHandler: { [weak self] in
24+
guard let self else { return false }
25+
// Skip ping while a user query is in-flight to avoid racing
26+
// on the same non-thread-safe driver connection.
27+
// Allow ping if the query appears stuck (exceeds timeout + grace period).
28+
if await self.queriesInFlight[connectionId] != nil {
29+
let queryTimeout = await TimeInterval(AppSettingsManager.shared.general.queryTimeoutSeconds)
30+
let maxStale = max(queryTimeout, 300) // At least 5 minutes
31+
if let startTime = await self.queryStartTimes[connectionId],
32+
Date().timeIntervalSince(startTime) < maxStale {
33+
return true // Query still within expected time
34+
}
35+
// Query appears stuck — fall through to ping
36+
}
37+
guard let mainDriver = await self.activeSessions[connectionId]?.driver else {
38+
return false
39+
}
40+
do {
41+
_ = try await mainDriver.execute(query: "SELECT 1")
42+
return true
43+
} catch {
44+
Self.logger.debug("Ping failed: \(error.localizedDescription)")
45+
return false
46+
}
47+
},
48+
reconnectHandler: { [weak self] in
49+
guard let self else { return false }
50+
guard let session = await self.activeSessions[connectionId] else { return false }
51+
do {
52+
let driver = try await self.trackOperation(sessionId: connectionId) {
53+
try await self.reconnectDriver(for: session)
54+
}
55+
await self.updateSession(connectionId) { session in
56+
session.driver = driver
57+
session.status = .connected
58+
}
59+
return true
60+
} catch {
61+
Self.logger.debug("Reconnect failed: \(error.localizedDescription)")
62+
return false
63+
}
64+
},
65+
onStateChanged: { [weak self] id, state in
66+
guard let self else { return }
67+
await MainActor.run {
68+
switch state {
69+
case .healthy:
70+
// Skip no-op write — avoid firing @Published when status is already .connected
71+
if let session = self.activeSessions[id], !session.isConnected {
72+
self.updateSession(id) { session in
73+
session.status = .connected
74+
}
75+
}
76+
case .reconnecting(let attempt):
77+
Self.logger.info("Reconnecting session \(id) (attempt \(attempt))")
78+
if case .connecting = self.activeSessions[id]?.status {
79+
// Already .connecting — skip redundant write
80+
} else {
81+
self.updateSession(id) { session in
82+
session.status = .connecting
83+
}
84+
}
85+
case .failed:
86+
Self.logger.error(
87+
"Health monitoring failed for session \(id)")
88+
self.updateSession(id) { session in
89+
session.status = .error(String(localized: "Connection lost"))
90+
session.clearCachedData()
91+
}
92+
case .checking:
93+
break // No UI update needed
94+
}
95+
}
96+
}
97+
)
98+
99+
healthMonitors[connectionId] = monitor
100+
await monitor.startMonitoring()
101+
}
102+
103+
/// Creates a fresh driver, connects, and applies timeout for the given session.
104+
/// Uses the session's effective connection (SSH-tunneled if applicable).
105+
internal func reconnectDriver(for session: ConnectionSession) async throws -> DatabaseDriver {
106+
// Disconnect existing driver
107+
session.driver?.disconnect()
108+
109+
// Use effective connection (tunneled) if available, otherwise original
110+
let connectionForDriver = session.effectiveConnection ?? session.connection
111+
let driver = try DatabaseDriverFactory.createDriver(
112+
for: connectionForDriver,
113+
passwordOverride: session.cachedPassword
114+
)
115+
try await driver.connect()
116+
117+
// Apply timeout
118+
let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds
119+
if timeoutSeconds > 0 {
120+
try await driver.applyQueryTimeout(timeoutSeconds)
121+
}
122+
123+
await executeStartupCommands(
124+
session.connection.startupCommands, on: driver, connectionName: session.connection.name
125+
)
126+
127+
if let savedSchema = session.currentSchema,
128+
let schemaDriver = driver as? SchemaSwitchable {
129+
do {
130+
try await schemaDriver.switchSchema(to: savedSchema)
131+
} catch {
132+
Self.logger.warning("Failed to restore schema '\(savedSchema)' on reconnect: \(error.localizedDescription)")
133+
}
134+
}
135+
136+
// Restore database for MSSQL if session had a non-default database
137+
if let savedDatabase = session.currentDatabase,
138+
let adapter = driver as? PluginDriverAdapter {
139+
do {
140+
try await adapter.switchDatabase(to: savedDatabase)
141+
} catch {
142+
Self.logger.warning("Failed to restore database '\(savedDatabase)' on reconnect: \(error.localizedDescription)")
143+
}
144+
}
145+
146+
return driver
147+
}
148+
149+
/// Stop health monitoring for a connection
150+
internal func stopHealthMonitor(for connectionId: UUID) async {
151+
if let monitor = healthMonitors.removeValue(forKey: connectionId) {
152+
await monitor.stopMonitoring()
153+
}
154+
}
155+
156+
/// Reconnect the current session (called from toolbar Reconnect button)
157+
func reconnectCurrentSession() async {
158+
guard let sessionId = currentSessionId else { return }
159+
await reconnectSession(sessionId)
160+
}
161+
162+
/// Reconnect a specific session by ID
163+
func reconnectSession(_ sessionId: UUID) async {
164+
guard let session = activeSessions[sessionId] else { return }
165+
166+
Self.logger.info("Manual reconnect requested for: \(session.connection.name)")
167+
168+
// Update status to connecting
169+
updateSession(sessionId) { session in
170+
session.status = .connecting
171+
}
172+
173+
// Stop existing health monitor
174+
await stopHealthMonitor(for: sessionId)
175+
176+
do {
177+
// Disconnect existing driver (re-fetch to avoid stale local reference)
178+
activeSessions[sessionId]?.driver?.disconnect()
179+
180+
// Recreate SSH tunnel if needed and build effective connection
181+
let effectiveConnection = try await buildEffectiveConnection(for: session.connection)
182+
183+
// Resolve password for prompt-for-password connections
184+
var passwordOverride = activeSessions[sessionId]?.cachedPassword
185+
if session.connection.promptForPassword && passwordOverride == nil {
186+
let isApiOnly = PluginManager.shared.connectionMode(for: session.connection.type) == .apiOnly
187+
guard let prompted = await PasswordPromptHelper.prompt(
188+
connectionName: session.connection.name,
189+
isAPIToken: isApiOnly,
190+
window: NSApp.keyWindow
191+
) else {
192+
updateSession(sessionId) { $0.status = .disconnected }
193+
return
194+
}
195+
passwordOverride = prompted
196+
}
197+
198+
// Create new driver and connect
199+
let driver = try DatabaseDriverFactory.createDriver(
200+
for: effectiveConnection,
201+
passwordOverride: passwordOverride
202+
)
203+
try await driver.connect()
204+
205+
// Apply timeout
206+
let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds
207+
if timeoutSeconds > 0 {
208+
try await driver.applyQueryTimeout(timeoutSeconds)
209+
}
210+
211+
await executeStartupCommands(
212+
session.connection.startupCommands, on: driver, connectionName: session.connection.name
213+
)
214+
215+
if let savedSchema = activeSessions[sessionId]?.currentSchema,
216+
let schemaDriver = driver as? SchemaSwitchable {
217+
do {
218+
try await schemaDriver.switchSchema(to: savedSchema)
219+
} catch {
220+
Self.logger.warning("Failed to restore schema '\(savedSchema)' on reconnect: \(error.localizedDescription)")
221+
}
222+
}
223+
224+
// Restore database for MSSQL if session had a non-default database
225+
if let savedDatabase = activeSessions[sessionId]?.currentDatabase,
226+
let adapter = driver as? PluginDriverAdapter {
227+
do {
228+
try await adapter.switchDatabase(to: savedDatabase)
229+
} catch {
230+
Self.logger.warning("Failed to restore database '\(savedDatabase)' on reconnect: \(error.localizedDescription)")
231+
}
232+
}
233+
234+
// Update session
235+
updateSession(sessionId) { session in
236+
session.driver = driver
237+
session.status = .connected
238+
session.effectiveConnection = effectiveConnection
239+
if let passwordOverride {
240+
session.cachedPassword = passwordOverride
241+
}
242+
}
243+
244+
// Restart health monitoring if the plugin supports it
245+
let supportsHealthReconnect = PluginMetadataRegistry.shared.snapshot(
246+
forTypeId: session.connection.type.pluginTypeId
247+
)?.supportsHealthMonitor ?? true
248+
249+
if supportsHealthReconnect {
250+
await startHealthMonitor(for: sessionId)
251+
}
252+
253+
// Post connection notification for schema reload
254+
NotificationCenter.default.post(name: .databaseDidConnect, object: nil)
255+
256+
Self.logger.info("Manual reconnect succeeded for: \(session.connection.name)")
257+
} catch {
258+
Self.logger.error("Manual reconnect failed: \(error.localizedDescription)")
259+
updateSession(sessionId) { session in
260+
session.status = .error(
261+
String(format: String(localized: "Reconnect failed: %@"), error.localizedDescription))
262+
session.clearCachedData()
263+
}
264+
}
265+
}
266+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
//
2+
// DatabaseManager+Queries.swift
3+
// TablePro
4+
//
5+
// Created by Ngo Quoc Dat on 16/12/25.
6+
//
7+
8+
import Foundation
9+
import os
10+
import TableProPluginKit
11+
12+
// MARK: - Query Execution
13+
14+
extension DatabaseManager {
15+
/// Track an in-flight operation for the given session, preventing health monitor
16+
/// pings from racing on the same non-thread-safe driver connection.
17+
internal func trackOperation<T>(
18+
sessionId: UUID,
19+
operation: () async throws -> T
20+
) async throws -> T {
21+
queriesInFlight[sessionId, default: 0] += 1
22+
if queriesInFlight[sessionId] == 1 {
23+
queryStartTimes[sessionId] = Date()
24+
}
25+
defer {
26+
if let count = queriesInFlight[sessionId], count > 1 {
27+
queriesInFlight[sessionId] = count - 1
28+
} else {
29+
queriesInFlight.removeValue(forKey: sessionId)
30+
queryStartTimes.removeValue(forKey: sessionId)
31+
}
32+
}
33+
return try await operation()
34+
}
35+
36+
/// Execute a query on the current session
37+
func execute(query: String) async throws -> QueryResult {
38+
guard let sessionId = currentSessionId, let driver = activeDriver else {
39+
throw DatabaseError.notConnected
40+
}
41+
42+
return try await trackOperation(sessionId: sessionId) {
43+
try await driver.execute(query: query)
44+
}
45+
}
46+
47+
/// Fetch tables from the current session
48+
func fetchTables() async throws -> [TableInfo] {
49+
guard let sessionId = currentSessionId, let driver = activeDriver else {
50+
throw DatabaseError.notConnected
51+
}
52+
53+
return try await trackOperation(sessionId: sessionId) {
54+
try await driver.fetchTables()
55+
}
56+
}
57+
58+
/// Fetch columns for a table from the current session
59+
func fetchColumns(table: String) async throws -> [ColumnInfo] {
60+
guard let sessionId = currentSessionId, let driver = activeDriver else {
61+
throw DatabaseError.notConnected
62+
}
63+
64+
return try await trackOperation(sessionId: sessionId) {
65+
try await driver.fetchColumns(table: table)
66+
}
67+
}
68+
69+
/// Test a connection without keeping it open
70+
func testConnection(
71+
_ connection: DatabaseConnection,
72+
sshPassword: String? = nil,
73+
passwordOverride: String? = nil
74+
) async throws -> Bool {
75+
// Build effective connection (creates SSH tunnel if needed)
76+
let testConnection = try await buildEffectiveConnection(
77+
for: connection,
78+
sshPasswordOverride: sshPassword
79+
)
80+
81+
let result: Bool
82+
do {
83+
let driver = try DatabaseDriverFactory.createDriver(
84+
for: testConnection,
85+
passwordOverride: passwordOverride
86+
)
87+
result = try await driver.testConnection()
88+
} catch {
89+
if connection.sshConfig.enabled {
90+
do {
91+
try await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
92+
} catch {
93+
Self.logger.warning("SSH tunnel cleanup failed for \(connection.name): \(error.localizedDescription)")
94+
}
95+
}
96+
throw error
97+
}
98+
99+
if connection.sshConfig.enabled {
100+
do {
101+
try await SSHTunnelManager.shared.closeTunnel(connectionId: connection.id)
102+
} catch {
103+
Self.logger.warning("SSH tunnel cleanup failed for \(connection.name): \(error.localizedDescription)")
104+
}
105+
}
106+
107+
return result
108+
}
109+
}

0 commit comments

Comments
 (0)