diff --git a/CHANGELOG.md b/CHANGELOG.md index 4737d8292..d9d8f0290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Data grid row context menus now copy the clicked or focused cell value for Copy, while Copy Rows still keeps the full-row TSV action. - Opening a table in a new tab now restores saved hidden columns before the first load, so the initial query matches the visible column set. - The JSON detail popover now shows long string values up to 300 characters in the tree view instead of cutting them off at 80. +- Restoring or previewing a table no longer leaves the Tables sidebar spinner stuck after the table list has already loaded. ## [0.45.0] - 2026-05-26 diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index eac104d56..e8a3c251a 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -39,6 +39,8 @@ final class SchemaService { let schema: String } @ObservationIgnored private var schemaChangeCancellable: AnyCancellable? + @ObservationIgnored private var loadGenerations: [UUID: Int] = [:] + @ObservationIgnored private var nextLoadGeneration = 0 @ObservationIgnored private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService") init() { @@ -181,6 +183,7 @@ final class SchemaService { await perSchemaDedup.cancel(key: SchemaKey(connectionId: connectionId, schema: schema)) } } + loadGenerations.removeValue(forKey: connectionId) states.removeValue(forKey: connectionId) procedures.removeValue(forKey: connectionId) functions.removeValue(forKey: connectionId) @@ -201,6 +204,7 @@ final class SchemaService { driver: DatabaseDriver, connection: DatabaseConnection ) async { + let generation = beginLoadGeneration(for: connectionId) states[connectionId] = .loading bumpGeneration(connectionId) @@ -211,7 +215,7 @@ final class SchemaService { let grouping = PluginManager.shared.databaseGroupingStrategy(for: connection.type) if grouping == .hierarchicalSchema { - await runHierarchicalLoad(connectionId: connectionId, driver: driver) + await runHierarchicalLoad(connectionId: connectionId, driver: driver, generation: generation) return } @@ -230,22 +234,49 @@ final class SchemaService { dedup: functionDedup, fetch: { try await driver.fetchFunctions(schema: nil) } ) - - let loadedProcedures = await proceduresTask - let loadedFunctions = await functionsTask - if supportsSchemas { - await loadSchemaList(connectionId: connectionId, driver: driver) - } + async let schemasTask: [String]? = supportsSchemas + ? Self.fetchSchemasSafely( + connectionId: connectionId, + dedup: schemasDedup, + fetch: { try await driver.fetchSchemas() } + ) + : nil do { let tables = try await tablesTask + guard isCurrentLoadGeneration(generation, for: connectionId, phase: "tables-loaded") else { + return + } states[connectionId] = .loaded(tables) + + let loadedProcedures = await proceduresTask + guard isCurrentLoadGeneration(generation, for: connectionId, phase: "procedures-loaded") else { + return + } procedures[connectionId] = loadedProcedures + + let loadedFunctions = await functionsTask + guard isCurrentLoadGeneration(generation, for: connectionId, phase: "functions-loaded") else { + return + } functions[connectionId] = loadedFunctions + + if let loadedSchemas = await schemasTask { + guard isCurrentLoadGeneration(generation, for: connectionId, phase: "schemas-loaded") else { + return + } + schemasInOrder[connectionId] = loadedSchemas + } bumpGeneration(connectionId) } catch is CancellationError { return } catch { + guard isCurrentLoadGeneration(generation, for: connectionId, phase: "tables-failed") else { + if loadGenerations[connectionId] == nil, case .loading = states[connectionId] { + states[connectionId] = .idle + } + return + } Self.logger.warning( "[schema] load failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) @@ -254,7 +285,7 @@ final class SchemaService { } } - private func runHierarchicalLoad(connectionId: UUID, driver: DatabaseDriver) async { + private func runHierarchicalLoad(connectionId: UUID, driver: DatabaseDriver, generation: Int) async { async let proceduresTask: [RoutineInfo] = Self.fetchRoutinesSafely( connectionId: connectionId, kind: .procedure, @@ -270,27 +301,66 @@ final class SchemaService { let loadedProcedures = await proceduresTask let loadedFunctions = await functionsTask - await loadSchemaList(connectionId: connectionId, driver: driver) + let loadedSchemas = await Self.fetchSchemasSafely( + connectionId: connectionId, + dedup: schemasDedup, + fetch: { try await driver.fetchSchemas() } + ) + guard isCurrentLoadGeneration(generation, for: connectionId, phase: "hierarchical-loaded") else { + return + } + if let loadedSchemas { + schemasInOrder[connectionId] = loadedSchemas + } procedures[connectionId] = loadedProcedures functions[connectionId] = loadedFunctions states[connectionId] = .loaded([]) bumpGeneration(connectionId) } - private func loadSchemaList(connectionId: UUID, driver: DatabaseDriver) async { + private func beginLoadGeneration(for connectionId: UUID) -> Int { + nextLoadGeneration += 1 + let generation = nextLoadGeneration + if case .loading? = states[connectionId] { + let previousGeneration = loadGenerations[connectionId] ?? 0 + Self.logger.debug( + "[schema] superseding in-flight load connId=\(connectionId, privacy: .public) previousGeneration=\(previousGeneration) newGeneration=\(generation)" + ) + } + loadGenerations[connectionId] = generation + return generation + } + + private func isCurrentLoadGeneration( + _ generation: Int, + for connectionId: UUID, + phase: String + ) -> Bool { + guard loadGenerations[connectionId] == generation else { + let currentGeneration = loadGenerations[connectionId] ?? 0 + Self.logger.debug( + "[schema] stale load transition ignored connId=\(connectionId, privacy: .public) phase=\(phase, privacy: .public) generation=\(generation) currentGeneration=\(currentGeneration)" + ) + return false + } + return true + } + + private static func fetchSchemasSafely( + connectionId: UUID, + dedup: OnceTask, + fetch: @Sendable @escaping () async throws -> [String] + ) async -> [String]? { do { - let allSchemas = try await schemasDedup.execute(key: connectionId) { - try await driver.fetchSchemas() - } - schemasInOrder[connectionId] = allSchemas - bumpGeneration(connectionId) + return try await dedup.execute(key: connectionId, work: fetch) } catch is CancellationError { - return + return nil } catch { Self.logger.warning( "[schema] fetchSchemas failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) + return nil } } diff --git a/TableProTests/Services/SchemaServiceRoutinesTests.swift b/TableProTests/Services/SchemaServiceRoutinesTests.swift index eaa7a4dac..a4865b3b0 100644 --- a/TableProTests/Services/SchemaServiceRoutinesTests.swift +++ b/TableProTests/Services/SchemaServiceRoutinesTests.swift @@ -6,6 +6,7 @@ import Foundation import TableProPluginKit import Testing + @testable import TablePro private final class RoutineMockDriver: DatabaseDriver, @unchecked Sendable { @@ -153,6 +154,116 @@ private final class FailingRoutineDriver: DatabaseDriver, @unchecked Sendable { } } +private actor AsyncGate { + private var waiters: [CheckedContinuation] = [] + private var isOpen = false + + func wait() async { + if isOpen { + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func open() { + guard !isOpen else { return } + isOpen = true + let currentWaiters = waiters + waiters.removeAll() + for continuation in currentWaiters { + continuation.resume() + } + } +} + +private final class BlockingAuxiliaryDriver: DatabaseDriver, @unchecked Sendable { + let connection: DatabaseConnection + var status: ConnectionStatus = .connected + var serverVersion: String? { nil } + + var tablesToReturn: [TableInfo] = [] + var proceduresToReturn: [RoutineInfo] = [] + var functionsToReturn: [RoutineInfo] = [] + var schemasToReturn: [String] = [] + + let tablesGate = AsyncGate() + let routinesGate = AsyncGate() + let schemasGate = AsyncGate() + + init(connection: DatabaseConnection = TestFixtures.makeConnection()) { + self.connection = connection + } + + func connect() async throws {} + func disconnect() {} + func testConnection() async throws -> Bool { true } + func applyQueryTimeout(_ seconds: Int) async throws {} + + func execute(query: String) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func fetchTables() async throws -> [TableInfo] { + await tablesGate.wait() + return tablesToReturn + } + + func fetchColumns(table: String) async throws -> [ColumnInfo] { [] } + func fetchIndexes(table: String) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { [] } + func fetchApproximateRowCount(table: String) async throws -> Int? { nil } + func fetchTableDDL(table: String) async throws -> String { "" } + func fetchViewDefinition(view: String) async throws -> String { "" } + + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + TableMetadata( + tableName: tableName, dataSize: nil, indexSize: nil, totalSize: nil, + avgRowLength: nil, rowCount: nil, comment: nil, engine: nil, + collation: nil, createTime: nil, updateTime: nil + ) + } + + func fetchDatabases() async throws -> [String] { [] } + + func fetchSchemas() async throws -> [String] { + await schemasGate.wait() + return schemasToReturn + } + + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + DatabaseMetadata( + id: database, name: database, tableCount: nil, sizeBytes: nil, + lastAccessed: nil, isSystemDatabase: false, icon: "cylinder" + ) + } + + func cancelQuery() throws {} + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} + + func fetchProcedures(schema: String?) async throws -> [RoutineInfo] { + await routinesGate.wait() + return proceduresToReturn + } + + func fetchFunctions(schema: String?) async throws -> [RoutineInfo] { + await routinesGate.wait() + return functionsToReturn + } +} + @Suite("SchemaService routines") @MainActor struct SchemaServiceRoutinesTests { @@ -240,6 +351,51 @@ struct SchemaServiceRoutinesTests { #expect(service.functions(for: connectionId).isEmpty) } + @Test("table state becomes loaded before auxiliary metadata finishes") + func tableStateLoadsBeforeAuxiliaryMetadata() async { + let service = SchemaService() + let connectionId = UUID() + let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) + let driver = BlockingAuxiliaryDriver(connection: connection) + driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "users")] + driver.proceduresToReturn = [ + RoutineInfo(name: "add_user", schema: "public", kind: .procedure, signature: nil) + ] + driver.functionsToReturn = [ + RoutineInfo(name: "user_count", schema: "public", kind: .function, signature: "int") + ] + driver.schemasToReturn = ["public"] + + let loadTask = Task { + await service.load(connectionId: connectionId, driver: driver, connection: connection) + } + + await driver.tablesGate.open() + await waitForLoadedState(service, connectionId: connectionId) + + #expect(service.tables(for: connectionId).map(\.name) == ["users"]) + #expect(service.procedures(for: connectionId).isEmpty) + #expect(service.functions(for: connectionId).isEmpty) + #expect(service.schemas(for: connectionId).isEmpty) + + await driver.routinesGate.open() + await driver.schemasGate.open() + await loadTask.value + + #expect(service.procedures(for: connectionId).map(\.name) == ["add_user"]) + #expect(service.functions(for: connectionId).map(\.name) == ["user_count"]) + #expect(service.schemas(for: connectionId) == ["public"]) + } + + private func waitForLoadedState(_ service: SchemaService, connectionId: UUID) async { + while true { + if case .loaded = service.state(for: connectionId) { + return + } + await Task.yield() + } + } + @Test("reloadProcedures refreshes only procedures") func reloadProceduresOnly() async { let service = SchemaService()