diff --git a/CHANGELOG.md b/CHANGELOG.md index cf6a3a304..6769fa943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed the sidebar and the app lagging on connections with hundreds or thousands of tables. Loading a connection's stored procedures or functions no longer rebuilds the whole table list, and a background load on one open connection no longer redraws the sidebar of every other window. - Fixed a query with a comment after the closing semicolon, such as `SELECT 1; -- note`, being run as two statements, with the trailing comment sent to the server as a failing second statement. Running a comment-only query or selection now does nothing instead of producing a server error, and AI and MCP clients are no longer told such a query is multi-statement. (#1895) - Fixed duplicating a connection dropping its Cloudflare Tunnel and Cloud SQL Auth Proxy settings and stored secrets. - Fixed the tunnel panes warning about only some of the other enabled connection methods. Each pane now lists every conflicting method with a button to turn it off. diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index b3acf68a2..40bf56b60 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -359,6 +359,7 @@ extension DatabaseManager { SharedSidebarState.removeConnection(sessionId) SidebarViewModel.removeConnection(sessionId) + SchemaConnectionState.removeConnection(sessionId) if currentSessionId == sessionId { if let nextSessionId = activeSessions.keys.first { diff --git a/TablePro/Core/Services/Query/DatabaseTreeConnectionState.swift b/TablePro/Core/Services/Query/DatabaseTreeConnectionState.swift new file mode 100644 index 000000000..5fba5d115 --- /dev/null +++ b/TablePro/Core/Services/Query/DatabaseTreeConnectionState.swift @@ -0,0 +1,67 @@ +// +// DatabaseTreeConnectionState.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +@MainActor +@Observable +final class DatabaseTreeConnectionState { + typealias DatabaseKey = DatabaseTreeMetadataService.DatabaseKey + typealias ObjectsKey = DatabaseTreeMetadataService.ObjectsKey + + private(set) var databaseList: MetadataLoadState<[DatabaseMetadata]> = .idle + private(set) var schemaList: [DatabaseKey: MetadataLoadState<[String]>] = [:] + private(set) var tablesState: [ObjectsKey: MetadataLoadState<[TableInfo]>] = [:] + private(set) var routinesState: [ObjectsKey: MetadataLoadState<[RoutineInfo]>] = [:] + + private static var registry: [UUID: DatabaseTreeConnectionState] = [:] + + static func forConnection(_ connectionId: UUID) -> DatabaseTreeConnectionState { + if let existing = registry[connectionId] { return existing } + let created = DatabaseTreeConnectionState() + registry[connectionId] = created + return created + } + + static func removeConnection(_ connectionId: UUID) { + registry.removeValue(forKey: connectionId) + } + + func setDatabaseList(_ state: MetadataLoadState<[DatabaseMetadata]>) { + databaseList = state + } + + func setSchemaList(_ state: MetadataLoadState<[String]>, key: DatabaseKey) { + schemaList[key] = state + } + + func removeSchemaList(key: DatabaseKey) { + schemaList.removeValue(forKey: key) + } + + func setTablesState(_ state: MetadataLoadState<[TableInfo]>, key: ObjectsKey) { + tablesState[key] = state + } + + func removeTablesState(key: ObjectsKey) { + tablesState.removeValue(forKey: key) + } + + func setRoutinesState(_ state: MetadataLoadState<[RoutineInfo]>, key: ObjectsKey) { + routinesState[key] = state + } + + func removeRoutinesState(key: ObjectsKey) { + routinesState.removeValue(forKey: key) + } + + func reset() { + databaseList = .idle + schemaList = [:] + tablesState = [:] + routinesState = [:] + } +} diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index a2c73c39e..51b2564a8 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -23,10 +23,9 @@ final class DatabaseTreeMetadataService { let schema: String? } - private(set) var databaseList: [UUID: MetadataLoadState<[DatabaseMetadata]>] = [:] - private(set) var schemaList: [DatabaseKey: MetadataLoadState<[String]>] = [:] - private(set) var tablesState: [ObjectsKey: MetadataLoadState<[TableInfo]>] = [:] - private(set) var routinesState: [ObjectsKey: MetadataLoadState<[RoutineInfo]>] = [:] + private func holder(_ connectionId: UUID) -> DatabaseTreeConnectionState { + DatabaseTreeConnectionState.forConnection(connectionId) + } @ObservationIgnored private let databaseDedup = OnceTask() @ObservationIgnored private let schemaDedup = OnceTask() @@ -42,35 +41,35 @@ final class DatabaseTreeMetadataService { // MARK: - Reads func databaseListState(for connectionId: UUID) -> MetadataLoadState<[DatabaseMetadata]> { - databaseList[connectionId] ?? .idle + holder(connectionId).databaseList } func databases(for connectionId: UUID) -> [DatabaseMetadata] { - databaseList[connectionId]?.value ?? [] + holder(connectionId).databaseList.value ?? [] } func schemaListState(connectionId: UUID, database: String) -> MetadataLoadState<[String]> { - schemaList[DatabaseKey(connectionId: connectionId, database: database)] ?? .idle + holder(connectionId).schemaList[DatabaseKey(connectionId: connectionId, database: database)] ?? .idle } func schemas(connectionId: UUID, database: String) -> [String] { - schemaList[DatabaseKey(connectionId: connectionId, database: database)]?.value ?? [] + holder(connectionId).schemaList[DatabaseKey(connectionId: connectionId, database: database)]?.value ?? [] } func tablesLoadState(connectionId: UUID, database: String, schema: String?) -> MetadataLoadState<[TableInfo]> { - tablesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)] ?? .idle + holder(connectionId).tablesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)] ?? .idle } func routinesLoadState(connectionId: UUID, database: String, schema: String?) -> MetadataLoadState<[RoutineInfo]> { - routinesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)] ?? .idle + holder(connectionId).routinesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)] ?? .idle } func tables(connectionId: UUID, database: String, schema: String?) -> [TableInfo] { - tablesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value ?? [] + holder(connectionId).tablesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value ?? [] } func routines(connectionId: UUID, database: String, schema: String?) -> [RoutineInfo] { - routinesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value ?? [] + holder(connectionId).routinesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value ?? [] } // MARK: - Loads @@ -81,7 +80,7 @@ final class DatabaseTreeMetadataService { case .loaded, .loading: return case .idle, .failed: break } - databaseList[connectionId] = .loading + holder(connectionId).setDatabaseList(.loading) let systemNames = Set(PluginManager.shared.systemDatabaseNames(for: databaseType)) do { let list = try await databaseDedup.execute(key: connectionId) { [self] in @@ -91,11 +90,11 @@ final class DatabaseTreeMetadataService { } } } - databaseList[connectionId] = .loaded(list) + holder(connectionId).setDatabaseList(.loaded(list)) } catch is CancellationError { - if case .loading = databaseList[connectionId] { databaseList[connectionId] = .idle } + if case .loading = holder(connectionId).databaseList { holder(connectionId).setDatabaseList(.idle) } } catch { - databaseList[connectionId] = .failed(error.localizedDescription) + holder(connectionId).setDatabaseList(.failed(error.localizedDescription)) Self.logger.warning("databases load failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)") } } @@ -103,22 +102,22 @@ final class DatabaseTreeMetadataService { func loadSchemas(connectionId: UUID, database: String) async { guard isConnected(connectionId) else { return } let key = DatabaseKey(connectionId: connectionId, database: database) - switch schemaList[key] ?? .idle { + switch holder(connectionId).schemaList[key] ?? .idle { case .loaded, .loading: return case .idle, .failed: break } - schemaList[key] = .loading + holder(connectionId).setSchemaList(.loading, key: key) do { let list = try await schemaDedup.execute(key: key) { [self] in try await withDriver(connectionId: connectionId, database: database) { driver in try await driver.fetchSchemas() } } - schemaList[key] = .loaded(list) + holder(connectionId).setSchemaList(.loaded(list), key: key) } catch is CancellationError { - if case .loading = schemaList[key] { schemaList[key] = .idle } + if case .loading = holder(connectionId).schemaList[key] { holder(connectionId).setSchemaList(.idle, key: key) } } catch { - schemaList[key] = .failed(error.localizedDescription) + holder(connectionId).setSchemaList(.failed(error.localizedDescription), key: key) Self.logger.warning("schemas load failed db=\(database, privacy: .public) error=\(error.localizedDescription, privacy: .public)") } } @@ -126,11 +125,11 @@ final class DatabaseTreeMetadataService { func loadTables(connectionId: UUID, database: String, schema: String?) async { guard isConnected(connectionId) else { return } let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema) - switch tablesState[key] ?? .idle { + switch holder(connectionId).tablesState[key] ?? .idle { case .loaded, .loading: return case .idle, .failed: break } - tablesState[key] = .loading + holder(connectionId).setTablesState(.loading, key: key) let normalizedSchema = key.schema do { let list = try await tablesDedup.execute(key: key) { [self] in @@ -138,11 +137,11 @@ final class DatabaseTreeMetadataService { try await driver.fetchTables(schema: normalizedSchema) } } - tablesState[key] = .loaded(list) + holder(connectionId).setTablesState(.loaded(list), key: key) } catch is CancellationError { - if case .loading = tablesState[key] { tablesState[key] = .idle } + if case .loading = holder(connectionId).tablesState[key] { holder(connectionId).setTablesState(.idle, key: key) } } catch { - tablesState[key] = .failed(error.localizedDescription) + holder(connectionId).setTablesState(.failed(error.localizedDescription), key: key) Self.logger.warning( "tables load failed db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) @@ -152,11 +151,11 @@ final class DatabaseTreeMetadataService { func loadRoutines(connectionId: UUID, database: String, schema: String?) async { guard isConnected(connectionId) else { return } let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema) - switch routinesState[key] ?? .idle { + switch holder(connectionId).routinesState[key] ?? .idle { case .loaded, .loading: return case .idle, .failed: break } - routinesState[key] = .loading + holder(connectionId).setRoutinesState(.loading, key: key) let normalizedSchema = key.schema do { let list = try await routinesDedup.execute(key: key) { [self] in @@ -171,11 +170,11 @@ final class DatabaseTreeMetadataService { return procedures + functions } } - routinesState[key] = .loaded(list) + holder(connectionId).setRoutinesState(.loaded(list), key: key) } catch is CancellationError { - if case .loading = routinesState[key] { routinesState[key] = .idle } + if case .loading = holder(connectionId).routinesState[key] { holder(connectionId).setRoutinesState(.idle, key: key) } } catch { - routinesState[key] = .failed(error.localizedDescription) + holder(connectionId).setRoutinesState(.failed(error.localizedDescription), key: key) Self.logger.warning( "routines load failed db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) @@ -186,14 +185,14 @@ final class DatabaseTreeMetadataService { func refreshDatabases(connectionId: UUID, databaseType: DatabaseType) async { await databaseDedup.cancel(key: connectionId) - databaseList.removeValue(forKey: connectionId) + holder(connectionId).setDatabaseList(.idle) await loadDatabases(connectionId: connectionId, databaseType: databaseType) } func refreshSchemas(connectionId: UUID, database: String) async { let key = DatabaseKey(connectionId: connectionId, database: database) await schemaDedup.cancel(key: key) - schemaList.removeValue(forKey: key) + holder(connectionId).removeSchemaList(key: key) await loadSchemas(connectionId: connectionId, database: database) } @@ -201,16 +200,16 @@ final class DatabaseTreeMetadataService { let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema) await tablesDedup.cancel(key: key) await routinesDedup.cancel(key: key) - tablesState.removeValue(forKey: key) - routinesState.removeValue(forKey: key) + holder(connectionId).removeTablesState(key: key) + holder(connectionId).removeRoutinesState(key: key) async let tables = loadTables(connectionId: connectionId, database: database, schema: schema) async let routines = loadRoutines(connectionId: connectionId, database: database, schema: schema) _ = await (tables, routines) } func refreshLoadedTables(connectionId: UUID, database: String? = nil) async { - let keys = tablesState.keys.filter { key in - key.connectionId == connectionId && (database == nil || key.database == database) + let keys = holder(connectionId).tablesState.keys.filter { key in + database == nil || key.database == database } await withTaskGroup(of: Void.self) { group in for key in keys { @@ -231,8 +230,8 @@ final class DatabaseTreeMetadataService { } } let next: MetadataLoadState<[TableInfo]> = .loaded(list) - guard tablesState[key] != next else { return } - tablesState[key] = next + guard holder(key.connectionId).tablesState[key] != next else { return } + holder(key.connectionId).setTablesState(next, key: key) } catch is CancellationError { } catch { Self.logger.warning( @@ -250,9 +249,10 @@ final class DatabaseTreeMetadataService { func handleDisconnect(connectionId: UUID) async { MetadataConnectionPool.shared.closeAll(connectionId: connectionId) - let schemaKeys = schemaList.keys.filter { $0.connectionId == connectionId } + let state = holder(connectionId) + let schemaKeys = Array(state.schemaList.keys) let objectKeys = Self.connectionObjectKeys( - tableKeys: tablesState.keys, routineKeys: routinesState.keys, connectionId: connectionId + tableKeys: state.tablesState.keys, routineKeys: state.routinesState.keys, connectionId: connectionId ) await databaseDedup.cancel(key: connectionId) for key in schemaKeys { await schemaDedup.cancel(key: key) } @@ -260,36 +260,35 @@ final class DatabaseTreeMetadataService { await tablesDedup.cancel(key: key) await routinesDedup.cancel(key: key) } - databaseList.removeValue(forKey: connectionId) - schemaList = schemaList.filter { $0.key.connectionId != connectionId } - tablesState = tablesState.filter { $0.key.connectionId != connectionId } - routinesState = routinesState.filter { $0.key.connectionId != connectionId } + state.reset() + DatabaseTreeConnectionState.removeConnection(connectionId) } // MARK: - Private private func resetPending(connectionId: UUID) async { - let schemaKeys = schemaList.keys.filter { $0.connectionId == connectionId } + let state = holder(connectionId) + let schemaKeys = Array(state.schemaList.keys) let objectKeys = Self.connectionObjectKeys( - tableKeys: tablesState.keys, routineKeys: routinesState.keys, connectionId: connectionId + tableKeys: state.tablesState.keys, routineKeys: state.routinesState.keys, connectionId: connectionId ) - if isPending(databaseList[connectionId]) { + if isPending(state.databaseList) { await databaseDedup.cancel(key: connectionId) } - for key in schemaKeys where isPending(schemaList[key]) { + for key in schemaKeys where isPending(state.schemaList[key]) { await schemaDedup.cancel(key: key) } for key in objectKeys { - if isPending(tablesState[key]) { await tablesDedup.cancel(key: key) } - if isPending(routinesState[key]) { await routinesDedup.cancel(key: key) } + if isPending(state.tablesState[key]) { await tablesDedup.cancel(key: key) } + if isPending(state.routinesState[key]) { await routinesDedup.cancel(key: key) } } - if isPending(databaseList[connectionId]) { databaseList[connectionId] = .idle } - for key in schemaKeys where isPending(schemaList[key]) { schemaList[key] = .idle } + if isPending(state.databaseList) { state.setDatabaseList(.idle) } + for key in schemaKeys where isPending(state.schemaList[key]) { state.setSchemaList(.idle, key: key) } for key in objectKeys { - if isPending(tablesState[key]) { tablesState[key] = .idle } - if isPending(routinesState[key]) { routinesState[key] = .idle } + if isPending(state.tablesState[key]) { state.setTablesState(.idle, key: key) } + if isPending(state.routinesState[key]) { state.setRoutinesState(.idle, key: key) } } } diff --git a/TablePro/Core/Services/Query/SchemaConnectionState.swift b/TablePro/Core/Services/Query/SchemaConnectionState.swift new file mode 100644 index 000000000..da909c839 --- /dev/null +++ b/TablePro/Core/Services/Query/SchemaConnectionState.swift @@ -0,0 +1,123 @@ +// +// SchemaConnectionState.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +@MainActor +@Observable +final class SchemaConnectionState { + private(set) var state: SchemaState = .idle + private(set) var procedures: [RoutineInfo] = [] + private(set) var functions: [RoutineInfo] = [] + private(set) var schemasInOrder: [String] = [] + private(set) var perSchemaStates: [String: SchemaState] = [:] + + private(set) var tablesRevision = 0 + private(set) var routinesRevision = 0 + private(set) var schemasRevision = 0 + private(set) var perSchemaRevision = 0 + + private let connectionId: UUID + + private static var registry: [UUID: SchemaConnectionState] = [:] + + private init(connectionId: UUID) { + self.connectionId = connectionId + } + + static func forConnection(_ connectionId: UUID) -> SchemaConnectionState { + if let existing = registry[connectionId] { return existing } + let created = SchemaConnectionState(connectionId: connectionId) + registry[connectionId] = created + return created + } + + static func removeConnection(_ connectionId: UUID) { + registry.removeValue(forKey: connectionId) + } + + var tables: [TableInfo] { + if case .loaded(let tables) = state { return tables } + return [] + } + + func tables(inSchema schema: String) -> [TableInfo] { + if case .loaded(let tables) = perSchemaStates[schema] ?? .idle { return tables } + return [] + } + + func perSchemaState(_ schema: String) -> SchemaState { + perSchemaStates[schema] ?? .idle + } + + var allLoadedTables: [TableInfo] { + var result = tables + var seen = Set(result.map(\.id)) + for state in perSchemaStates.values { + guard case .loaded(let schemaTables) = state else { continue } + for table in schemaTables where seen.insert(table.id).inserted { + result.append(table) + } + } + return result + } + + func setState(_ newState: SchemaState) { + state = newState + tablesRevision &+= 1 + SidebarPerfSignpost.recordEvent("SchemaState.tables", connectionId: connectionId) + } + + func setProcedures(_ routines: [RoutineInfo]) { + procedures = routines + routinesRevision &+= 1 + SidebarPerfSignpost.recordEvent("SchemaState.routines", connectionId: connectionId) + } + + func setFunctions(_ routines: [RoutineInfo]) { + functions = routines + routinesRevision &+= 1 + SidebarPerfSignpost.recordEvent("SchemaState.routines", connectionId: connectionId) + } + + func setSchemasInOrder(_ schemas: [String]) { + schemasInOrder = schemas + schemasRevision &+= 1 + } + + func clearSchemasInOrder() { + guard !schemasInOrder.isEmpty else { return } + schemasInOrder = [] + schemasRevision &+= 1 + } + + func setPerSchemaState(_ newState: SchemaState, schema: String) { + perSchemaStates[schema] = newState + perSchemaRevision &+= 1 + } + + func clearPerSchemaState(schema: String) { + guard perSchemaStates[schema] != nil else { return } + perSchemaStates.removeValue(forKey: schema) + perSchemaRevision &+= 1 + } + + func loadedSchemaNames() -> [String] { + Array(perSchemaStates.keys) + } + + func reset() { + state = .idle + procedures = [] + functions = [] + schemasInOrder = [] + perSchemaStates = [:] + tablesRevision &+= 1 + routinesRevision &+= 1 + schemasRevision &+= 1 + perSchemaRevision &+= 1 + } +} diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index 21fbe61bd..ec432460c 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -13,19 +13,8 @@ import TableProPluginKit final class SchemaService { static let shared = SchemaService() - private(set) var states: [UUID: SchemaState] = [:] - private(set) var procedures: [UUID: [RoutineInfo]] = [:] - private(set) var functions: [UUID: [RoutineInfo]] = [:] - private(set) var schemasInOrder: [UUID: [String]] = [:] - private(set) var perSchemaStates: [UUID: [String: SchemaState]] = [:] - private(set) var generations: [UUID: Int] = [:] - - func generationToken(for connectionId: UUID) -> Int { - generations[connectionId] ?? 0 - } - - private func bumpGeneration(_ connectionId: UUID) { - generations[connectionId, default: 0] &+= 1 + private func holder(_ connectionId: UUID) -> SchemaConnectionState { + SchemaConnectionState.forConnection(connectionId) } @ObservationIgnored private let loadDedup = OnceTask() @@ -53,22 +42,19 @@ final class SchemaService { } func state(for connectionId: UUID) -> SchemaState { - states[connectionId] ?? .idle + holder(connectionId).state } func tables(for connectionId: UUID) -> [TableInfo] { - if case .loaded(let tables) = state(for: connectionId) { - return tables - } - return [] + holder(connectionId).tables } func procedures(for connectionId: UUID) -> [RoutineInfo] { - procedures[connectionId] ?? [] + holder(connectionId).procedures } func functions(for connectionId: UUID) -> [RoutineInfo] { - functions[connectionId] ?? [] + holder(connectionId).functions } func routines(for connectionId: UUID) -> [RoutineInfo] { @@ -76,73 +62,48 @@ final class SchemaService { } func schemas(for connectionId: UUID) -> [String] { - schemasInOrder[connectionId] ?? [] + holder(connectionId).schemasInOrder } func schemaState(for connectionId: UUID, schema: String) -> SchemaState { - perSchemaStates[connectionId]?[schema] ?? .idle + holder(connectionId).perSchemaState(schema) } func tables(for connectionId: UUID, schema: String) -> [TableInfo] { - if case .loaded(let tables) = schemaState(for: connectionId, schema: schema) { - return tables - } - return [] + holder(connectionId).tables(inSchema: schema) } /// Flat tables plus the union of every loaded per-schema table list. For /// hierarchicalSchema plugins the flat list is empty and this is the only /// way to see tables across schemas (e.g. for autocomplete). func allLoadedTables(for connectionId: UUID) -> [TableInfo] { - var result = tables(for: connectionId) - var seen = Set(result.map(\.id)) - for state in (perSchemaStates[connectionId] ?? [:]).values { - guard case .loaded(let schemaTables) = state else { continue } - for table in schemaTables where seen.insert(table.id).inserted { - result.append(table) - } - } - return result + holder(connectionId).allLoadedTables } func loadSchemaTables(connectionId: UUID, schema: String, driver: DatabaseDriver) async { if case .loaded = schemaState(for: connectionId, schema: schema) { return } - setPerSchemaState(.loading, connectionId: connectionId, schema: schema) + holder(connectionId).setPerSchemaState(.loading, schema: schema) do { let tables = try await perSchemaDedup.execute(key: SchemaKey(connectionId: connectionId, schema: schema)) { try await driver.fetchTables(schema: schema) } - setPerSchemaState(.loaded(tables), connectionId: connectionId, schema: schema) + holder(connectionId).setPerSchemaState(.loaded(tables), schema: schema) } catch is CancellationError { return } catch { Self.logger.warning( "[schema] per-schema load failed connId=\(connectionId, privacy: .public) schema=\(schema, privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) - setPerSchemaState(.failed(error.localizedDescription), connectionId: connectionId, schema: schema) + holder(connectionId).setPerSchemaState(.failed(error.localizedDescription), schema: schema) } } func reloadSchemaTables(connectionId: UUID, schema: String, driver: DatabaseDriver) async { await perSchemaDedup.cancel(key: SchemaKey(connectionId: connectionId, schema: schema)) - clearPerSchemaState(connectionId: connectionId, schema: schema) + holder(connectionId).clearPerSchemaState(schema: schema) await loadSchemaTables(connectionId: connectionId, schema: schema, driver: driver) } - private func setPerSchemaState(_ state: SchemaState, connectionId: UUID, schema: String) { - var inner = perSchemaStates[connectionId] ?? [:] - inner[schema] = state - perSchemaStates[connectionId] = inner - bumpGeneration(connectionId) - } - - private func clearPerSchemaState(connectionId: UUID, schema: String) { - guard var inner = perSchemaStates[connectionId] else { return } - inner.removeValue(forKey: schema) - perSchemaStates[connectionId] = inner - bumpGeneration(connectionId) - } - func load(connectionId: UUID, driver: DatabaseDriver, connection: DatabaseConnection) async { switch state(for: connectionId) { case .loaded: @@ -161,8 +122,7 @@ final class SchemaService { let routines = try await procedureDedup.execute(key: connectionId) { try await driver.fetchProcedures(schema: nil) } - procedures[connectionId] = routines - bumpGeneration(connectionId) + holder(connectionId).setProcedures(routines) } catch is CancellationError { return } catch { @@ -177,8 +137,7 @@ final class SchemaService { let routines = try await functionDedup.execute(key: connectionId) { try await driver.fetchFunctions(schema: nil) } - functions[connectionId] = routines - bumpGeneration(connectionId) + holder(connectionId).setFunctions(routines) } catch is CancellationError { return } catch { @@ -193,18 +152,11 @@ final class SchemaService { await procedureDedup.cancel(key: connectionId) await functionDedup.cancel(key: connectionId) await schemasDedup.cancel(key: connectionId) - if let schemas = perSchemaStates[connectionId]?.keys { - for schema in schemas { - await perSchemaDedup.cancel(key: SchemaKey(connectionId: connectionId, schema: schema)) - } + for schema in holder(connectionId).loadedSchemaNames() { + 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) - schemasInOrder.removeValue(forKey: connectionId) - perSchemaStates.removeValue(forKey: connectionId) - generations.removeValue(forKey: connectionId) + holder(connectionId).reset() } func refresh(connectionId: UUID) async { @@ -222,8 +174,7 @@ final class SchemaService { func markLoadFailed(connectionId: UUID, message: String) { if case .loaded = state(for: connectionId) { return } - states[connectionId] = .failed(message) - bumpGeneration(connectionId) + holder(connectionId).setState(.failed(message)) } private func runLoad( @@ -232,12 +183,11 @@ final class SchemaService { connection: DatabaseConnection ) async { let generation = beginLoadGeneration(for: connectionId) - states[connectionId] = .loading - bumpGeneration(connectionId) + holder(connectionId).setState(.loading) let supportsSchemas = PluginManager.shared.supportsSchemaSwitching(for: connection.type) if !supportsSchemas { - schemasInOrder.removeValue(forKey: connectionId) + holder(connectionId).clearSchemasInOrder() } let grouping = PluginManager.shared.databaseGroupingStrategy(for: connection.type) @@ -274,41 +224,39 @@ final class SchemaService { guard isCurrentLoadGeneration(generation, for: connectionId, phase: "tables-loaded") else { return } - states[connectionId] = .loaded(tables) + holder(connectionId).setState(.loaded(tables)) let loadedProcedures = await proceduresTask guard isCurrentLoadGeneration(generation, for: connectionId, phase: "procedures-loaded") else { return } - procedures[connectionId] = loadedProcedures + holder(connectionId).setProcedures(loadedProcedures) let loadedFunctions = await functionsTask guard isCurrentLoadGeneration(generation, for: connectionId, phase: "functions-loaded") else { return } - functions[connectionId] = loadedFunctions + holder(connectionId).setFunctions(loadedFunctions) if let loadedSchemas = await schemasTask { guard isCurrentLoadGeneration(generation, for: connectionId, phase: "schemas-loaded") else { return } - schemasInOrder[connectionId] = loadedSchemas + holder(connectionId).setSchemasInOrder(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 + if loadGenerations[connectionId] == nil, case .loading = state(for: connectionId) { + holder(connectionId).setState(.idle) } return } Self.logger.warning( "[schema] load failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) - states[connectionId] = .failed(error.localizedDescription) - bumpGeneration(connectionId) + holder(connectionId).setState(.failed(error.localizedDescription)) } } @@ -343,25 +291,23 @@ final class SchemaService { Self.logger.warning( "[schema] hierarchical schema list failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) - states[connectionId] = .failed(error.localizedDescription) - bumpGeneration(connectionId) + holder(connectionId).setState(.failed(error.localizedDescription)) return } guard isCurrentLoadGeneration(generation, for: connectionId, phase: "hierarchical-loaded") else { return } - schemasInOrder[connectionId] = loadedSchemas - procedures[connectionId] = loadedProcedures - functions[connectionId] = loadedFunctions - states[connectionId] = .loaded([]) - bumpGeneration(connectionId) + holder(connectionId).setSchemasInOrder(loadedSchemas) + holder(connectionId).setProcedures(loadedProcedures) + holder(connectionId).setFunctions(loadedFunctions) + holder(connectionId).setState(.loaded([])) } private func beginLoadGeneration(for connectionId: UUID) -> Int { nextLoadGeneration += 1 let generation = nextLoadGeneration - if case .loading? = states[connectionId] { + if case .loading = state(for: connectionId) { let previousGeneration = loadGenerations[connectionId] ?? 0 Self.logger.debug( "[schema] superseding in-flight load connId=\(connectionId, privacy: .public) previousGeneration=\(previousGeneration) newGeneration=\(generation)" @@ -438,8 +384,7 @@ final class SchemaService { await procedureDedup.cancel(key: connectionId) await functionDedup.cancel(key: connectionId) - states[connectionId] = .loading - bumpGeneration(connectionId) + holder(connectionId).setState(.loading) async let proceduresTask: [RoutineInfo] = Self.fetchRoutinesSafely( connectionId: connectionId, @@ -461,18 +406,16 @@ final class SchemaService { let tables = try await loadDedup.execute(key: connectionId) { try await driver.fetchTables() } - states[connectionId] = .loaded(tables) - procedures[connectionId] = loadedProcedures - functions[connectionId] = loadedFunctions - bumpGeneration(connectionId) + holder(connectionId).setState(.loaded(tables)) + holder(connectionId).setProcedures(loadedProcedures) + holder(connectionId).setFunctions(loadedFunctions) } catch is CancellationError { return } catch { Self.logger.warning( "[schema] current-schema reload failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) - states[connectionId] = .failed(error.localizedDescription) - bumpGeneration(connectionId) + holder(connectionId).setState(.failed(error.localizedDescription)) } } } diff --git a/TablePro/Core/Utilities/UI/SidebarPerfSignpost.swift b/TablePro/Core/Utilities/UI/SidebarPerfSignpost.swift new file mode 100644 index 000000000..84f69b7c1 --- /dev/null +++ b/TablePro/Core/Utilities/UI/SidebarPerfSignpost.swift @@ -0,0 +1,35 @@ +// +// SidebarPerfSignpost.swift +// TablePro +// + +import Foundation +import os + +internal enum SidebarPerfSignpost { + private static let subsystem = "com.TablePro" + + internal static let signposter = OSSignposter(subsystem: subsystem, category: "SidebarPerf") + internal static let logger = Logger(subsystem: subsystem, category: "SidebarPerf") + + internal static func recordBodyEvaluation(_ view: StaticString, connectionId: UUID) { + signposter.emitEvent(view, "connection=\(connectionId.uuidString, privacy: .public)") + logger.debug("body \(String(describing: view), privacy: .public) connection=\(connectionId.uuidString, privacy: .public)") + } + + internal static func recordEvent(_ name: StaticString, connectionId: UUID) { + signposter.emitEvent(name, "connection=\(connectionId.uuidString, privacy: .public)") + logger.debug("event \(String(describing: name), privacy: .public) connection=\(connectionId.uuidString, privacy: .public)") + } + + internal static func beginInterval( + _ name: StaticString, + connectionId: UUID + ) -> OSSignpostIntervalState { + signposter.beginInterval(name, id: signposter.makeSignpostID(), "connection=\(connectionId.uuidString, privacy: .public)") + } + + internal static func endInterval(_ name: StaticString, _ state: OSSignpostIntervalState) { + signposter.endInterval(name, state) + } +} diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index c2c4617aa..22ca1f772 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -88,7 +88,10 @@ final class SidebarViewModel { } private(set) var filterQuery = "" { - didSet { invalidateFilterCaches() } + didSet { + invalidateFilterCaches() + SidebarPerfSignpost.recordEvent("SidebarViewModel.filterQuery", connectionId: connectionId) + } } @ObservationIgnored private var filterDebounceTask: Task? @@ -354,13 +357,17 @@ final class SidebarViewModel { @ObservationIgnored private var cachedFilteredRoutines: [SidebarObjectKind: [RoutineInfo]] = [:] @ObservationIgnored private var cachedFilteredRoutinesFingerprint: (count: Int, generation: Int, query: String)? - private var schemaGeneration: Int { - SchemaService.shared.generationToken(for: connectionId) + private var tablesRevision: Int { + SchemaConnectionState.forConnection(connectionId).tablesRevision + } + + private var routinesRevision: Int { + SchemaConnectionState.forConnection(connectionId).routinesRevision } func tables(of kind: SidebarObjectKind, from tables: [TableInfo]) -> [TableInfo] { guard !kind.isRoutine else { return [] } - let fingerprint = (count: tables.count, generation: schemaGeneration) + let fingerprint = (count: tables.count, generation: tablesRevision) if cachedKindFingerprint?.count != fingerprint.count || cachedKindFingerprint?.generation != fingerprint.generation { rebuildKindBuckets(from: tables) @@ -371,7 +378,7 @@ final class SidebarViewModel { func filteredTables(of kind: SidebarObjectKind, from tables: [TableInfo]) -> [TableInfo] { let query = filterQuery - let fingerprint = (count: tables.count, generation: schemaGeneration, query: query) + let fingerprint = (count: tables.count, generation: tablesRevision, query: query) if cachedFilteredByKindFingerprint?.count != fingerprint.count || cachedFilteredByKindFingerprint?.generation != fingerprint.generation || cachedFilteredByKindFingerprint?.query != fingerprint.query { @@ -396,7 +403,7 @@ final class SidebarViewModel { func filteredRoutines(of kind: SidebarObjectKind, from routines: [RoutineInfo]) -> [RoutineInfo] { let query = filterQuery - let fingerprint = (count: routines.count, generation: schemaGeneration, query: query) + let fingerprint = (count: routines.count, generation: routinesRevision, query: query) if cachedFilteredRoutinesFingerprint?.count != fingerprint.count || cachedFilteredRoutinesFingerprint?.generation != fingerprint.generation || cachedFilteredRoutinesFingerprint?.query != fingerprint.query { diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index aa64a74ae..e6a99a74e 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -142,6 +142,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private func refresh() { guard let outlineView else { return } + let interval = SidebarPerfSignpost.beginInterval("DatabaseTree.refresh", connectionId: connectionId) + defer { SidebarPerfSignpost.endInterval("DatabaseTree.refresh", interval) } isReloading = true childrenCache.removeAll() outlineView.reloadData() @@ -473,7 +475,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject { activeSchema: activeSchema, systemSchemas: systemSchemas, pendingTruncates: pendingTruncates, - pendingDeletes: pendingDeletes + pendingDeletes: pendingDeletes, + showComment: AppSettingsManager.shared.general.showObjectComments ) } diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift index f565f5844..e93a230a9 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -29,6 +29,7 @@ struct DatabaseTreeRowContext { let systemSchemas: Set let pendingTruncates: Set let pendingDeletes: Set + let showComment: Bool } struct DatabaseTreeRowView: View { @@ -73,7 +74,8 @@ struct DatabaseTreeRowView: View { TableRow( table: ref.table, isPendingTruncate: context.pendingTruncates.contains(ref.table.name), - isPendingDelete: context.pendingDeletes.contains(ref.table.name) + isPendingDelete: context.pendingDeletes.contains(ref.table.name), + showComment: context.showComment ) .foregroundStyle(isEmphasized ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) case .database(let metadata): @@ -94,7 +96,8 @@ struct DatabaseTreeRowView: View { TableRow( table: ref.table, isPendingTruncate: context.pendingTruncates.contains(ref.table.name), - isPendingDelete: context.pendingDeletes.contains(ref.table.name) + isPendingDelete: context.pendingDeletes.contains(ref.table.name), + showComment: context.showComment ) .foregroundStyle(isEmphasized ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) case .routine(let ref): diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index 4beaead0b..bdcaa0880 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -126,7 +126,8 @@ struct SidebarTreeView: View { TableRow( table: table, isPendingTruncate: pendingTruncates.contains(table.name), - isPendingDelete: pendingDeletes.contains(table.name) + isPendingDelete: pendingDeletes.contains(table.name), + showComment: settingsManager.general.showObjectComments ) .tag(table) .contextMenu { @@ -156,7 +157,8 @@ struct SidebarTreeView: View { TableRow( table: table, isPendingTruncate: pendingTruncates.contains(table.name), - isPendingDelete: pendingDeletes.contains(table.name) + isPendingDelete: pendingDeletes.contains(table.name), + showComment: settingsManager.general.showObjectComments ) .selectionDisabled() .contentShape(Rectangle()) diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 393d0c2e7..be3de6c64 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -10,7 +10,7 @@ import TableProPluginKit struct SidebarView: View { @State private var viewModel: SidebarViewModel - @State private var favoriteTables: Set = [] + @State private var favoriteKeys: Set = [] @State private var settingsManager = AppSettingsManager.shared @State private var showDatabaseFilter: Bool = false @@ -99,7 +99,9 @@ struct SidebarView: View { // MARK: - Body var body: some View { - Group { + _ = Self._printChanges() + SidebarPerfSignpost.recordBodyEvaluation("SidebarView", connectionId: connectionId) + return Group { switch sidebarState.selectedSidebarTab { case .tables: VStack(spacing: 0) { @@ -347,12 +349,20 @@ struct SidebarView: View { } private func isFavorite(_ table: TableInfo) -> Bool { - favoriteTables.contains(FavoriteTablesStorage.FavoriteEntry( - connectionId: connectionId, - database: activeDatabase, - schema: table.schema, - name: table.name - )) + favoriteKeys.contains(Self.favoriteKey(schema: table.schema, name: table.name)) + } + + private static func favoriteKey(schema: String?, name: String) -> String { + "\(schema ?? "")\u{0}\(name)" + } + + private func recomputeFavoriteKeys() { + let database = activeDatabase + favoriteKeys = Set( + FavoriteTablesStorage.shared.favorites(for: connectionId) + .filter { $0.database == database } + .map { Self.favoriteKey(schema: $0.schema, name: $0.name) } + ) } private func toggleFavorite(_ table: TableInfo) { @@ -387,6 +397,7 @@ struct SidebarView: View { table: table, isPendingTruncate: pendingTruncates.contains(table.name), isPendingDelete: pendingDeletes.contains(table.name), + showComment: settingsManager.general.showObjectComments, isFavorite: isFavorite(table), onToggleFavorite: { toggleFavorite(table) } ) @@ -458,13 +469,16 @@ struct SidebarView: View { windowState.selectedTables.removeAll() } .onReceive(NotificationCenter.default.publisher(for: .favoriteTablesDidChange)) { _ in - favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) + recomputeFavoriteKeys() } .onChange(of: settingsManager.general.showRecentTables) { _, _ in sidebarState.reloadRecentTablesFromStore() } + .onChange(of: activeDatabase) { _, _ in + recomputeFavoriteKeys() + } .onAppear { - favoriteTables = FavoriteTablesStorage.shared.favorites(for: connectionId) + recomputeFavoriteKeys() } } @@ -508,6 +522,7 @@ struct SidebarView: View { table: table, isPendingTruncate: pendingTruncates.contains(table.name), isPendingDelete: pendingDeletes.contains(table.name), + showComment: settingsManager.general.showObjectComments, isFavorite: isFavorite(table), onToggleFavorite: { toggleFavorite(table) } ) diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index a5ad08a4e..ef8162da8 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -44,13 +44,30 @@ struct TableRow: View { let table: TableInfo let isPendingTruncate: Bool let isPendingDelete: Bool + let showComment: Bool var isFavorite: Bool = false var onToggleFavorite: (() -> Void)? @State private var isHovered = false + init( + table: TableInfo, + isPendingTruncate: Bool, + isPendingDelete: Bool, + showComment: Bool, + isFavorite: Bool = false, + onToggleFavorite: (() -> Void)? = nil + ) { + self.table = table + self.isPendingTruncate = isPendingTruncate + self.isPendingDelete = isPendingDelete + self.showComment = showComment + self.isFavorite = isFavorite + self.onToggleFavorite = onToggleFavorite + } + private var visibleComment: String? { - guard AppSettingsManager.shared.general.showObjectComments, + guard showComment, let comment = table.comment, !comment.isEmpty else { return nil } return comment diff --git a/TableProTests/Core/Services/Query/DatabaseTreeConnectionStateTests.swift b/TableProTests/Core/Services/Query/DatabaseTreeConnectionStateTests.swift new file mode 100644 index 000000000..bbb0e90f1 --- /dev/null +++ b/TableProTests/Core/Services/Query/DatabaseTreeConnectionStateTests.swift @@ -0,0 +1,77 @@ +// +// DatabaseTreeConnectionStateTests.swift +// TableProTests +// +// Per-connection tree metadata registry: each connection observes its own +// object, so one connection's database/table load cannot invalidate another +// connection's views. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("DatabaseTreeConnectionState") +@MainActor +struct DatabaseTreeConnectionStateTests { + private func objectsKey(_ connectionId: UUID, _ database: String, _ schema: String?) -> DatabaseTreeMetadataService.ObjectsKey { + DatabaseTreeMetadataService.ObjectsKey(connectionId: connectionId, database: database, schema: schema) + } + + @Test("forConnection returns the same instance for the same UUID") + func sameInstanceForSameId() { + let id = UUID() + let a = DatabaseTreeConnectionState.forConnection(id) + let b = DatabaseTreeConnectionState.forConnection(id) + #expect(a === b) + DatabaseTreeConnectionState.removeConnection(id) + } + + @Test("mutating one connection's tables leaves another connection untouched") + func mutationsAreIsolatedAcrossConnections() { + let idA = UUID() + let idB = UUID() + let a = DatabaseTreeConnectionState.forConnection(idA) + let b = DatabaseTreeConnectionState.forConnection(idB) + + a.setTablesState(.loaded([TestFixtures.makeTableInfo(name: "users")]), key: objectsKey(idA, "app", nil)) + + #expect(b.tablesState.isEmpty) + #expect(b.databaseList == .idle) + + DatabaseTreeConnectionState.removeConnection(idA) + DatabaseTreeConnectionState.removeConnection(idB) + } + + @Test("reset clears state in place, preserving instance identity") + func resetPreservesInstanceIdentity() { + let id = UUID() + let state = DatabaseTreeConnectionState.forConnection(id) + state.setDatabaseList(.loaded([DatabaseMetadata.minimal(name: "app", isSystem: false)])) + state.setTablesState(.loaded([TestFixtures.makeTableInfo(name: "users")]), key: objectsKey(id, "app", nil)) + + state.reset() + + #expect(state.databaseList == .idle) + #expect(state.tablesState.isEmpty) + #expect(DatabaseTreeConnectionState.forConnection(id) === state) + + DatabaseTreeConnectionState.removeConnection(id) + } + + @Test("removeConnection evicts the instance so a later lookup is fresh") + func removeConnectionEvicts() { + let id = UUID() + let first = DatabaseTreeConnectionState.forConnection(id) + first.setDatabaseList(.loaded([DatabaseMetadata.minimal(name: "app", isSystem: false)])) + + DatabaseTreeConnectionState.removeConnection(id) + let second = DatabaseTreeConnectionState.forConnection(id) + + #expect(first !== second) + #expect(second.databaseList == .idle) + + DatabaseTreeConnectionState.removeConnection(id) + } +} diff --git a/TableProTests/Core/Services/Query/SchemaConnectionStateTests.swift b/TableProTests/Core/Services/Query/SchemaConnectionStateTests.swift new file mode 100644 index 000000000..7e4c1fad8 --- /dev/null +++ b/TableProTests/Core/Services/Query/SchemaConnectionStateTests.swift @@ -0,0 +1,123 @@ +// +// SchemaConnectionStateTests.swift +// TableProTests +// +// Per-connection schema state registry: each connection observes its own +// object so one connection's load cannot invalidate another's views, and a +// routines load cannot invalidate the table list. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("SchemaConnectionState") +@MainActor +struct SchemaConnectionStateTests { + private func routine(_ name: String) -> RoutineInfo { + RoutineInfo(name: name, schema: nil, kind: .procedure, signature: nil) + } + + @Test("forConnection returns the same instance for the same UUID") + func sameInstanceForSameId() { + let id = UUID() + let a = SchemaConnectionState.forConnection(id) + let b = SchemaConnectionState.forConnection(id) + #expect(a === b) + SchemaConnectionState.removeConnection(id) + } + + @Test("forConnection returns different instances for different UUIDs") + func differentInstanceForDifferentId() { + let id1 = UUID() + let id2 = UUID() + let a = SchemaConnectionState.forConnection(id1) + let b = SchemaConnectionState.forConnection(id2) + #expect(a !== b) + SchemaConnectionState.removeConnection(id1) + SchemaConnectionState.removeConnection(id2) + } + + @Test("mutating one connection does not change another's revisions or state") + func mutationsAreIsolatedAcrossConnections() { + let idA = UUID() + let idB = UUID() + let a = SchemaConnectionState.forConnection(idA) + let b = SchemaConnectionState.forConnection(idB) + let bTablesRevision = b.tablesRevision + let bRoutinesRevision = b.routinesRevision + + a.setState(.loaded([TestFixtures.makeTableInfo(name: "users")])) + a.setProcedures([routine("do_thing")]) + + #expect(b.tablesRevision == bTablesRevision) + #expect(b.routinesRevision == bRoutinesRevision) + #expect(b.state == .idle) + + SchemaConnectionState.removeConnection(idA) + SchemaConnectionState.removeConnection(idB) + } + + @Test("a routines load bumps only the routines revision, never the tables revision") + func routineLoadDoesNotInvalidateTables() { + let id = UUID() + let state = SchemaConnectionState.forConnection(id) + state.setState(.loaded([TestFixtures.makeTableInfo(name: "users")])) + let tablesRevisionAfterLoad = state.tablesRevision + let routinesRevisionBefore = state.routinesRevision + + state.setProcedures([routine("a")]) + state.setFunctions([routine("b")]) + + #expect(state.tablesRevision == tablesRevisionAfterLoad) + #expect(state.routinesRevision > routinesRevisionBefore) + + SchemaConnectionState.removeConnection(id) + } + + @Test("a tables load bumps only the tables revision, never the routines revision") + func tableLoadDoesNotInvalidateRoutines() { + let id = UUID() + let state = SchemaConnectionState.forConnection(id) + state.setProcedures([routine("a")]) + let routinesRevisionAfterLoad = state.routinesRevision + let tablesRevisionBefore = state.tablesRevision + + state.setState(.loaded([TestFixtures.makeTableInfo(name: "users")])) + + #expect(state.routinesRevision == routinesRevisionAfterLoad) + #expect(state.tablesRevision > tablesRevisionBefore) + + SchemaConnectionState.removeConnection(id) + } + + @Test("reset returns the state to idle in place, preserving instance identity") + func resetPreservesInstanceIdentity() { + let id = UUID() + let state = SchemaConnectionState.forConnection(id) + state.setState(.loaded([TestFixtures.makeTableInfo(name: "users")])) + + state.reset() + + #expect(state.state == .idle) + #expect(SchemaConnectionState.forConnection(id) === state) + + SchemaConnectionState.removeConnection(id) + } + + @Test("removeConnection evicts the instance so a later lookup is fresh") + func removeConnectionEvicts() { + let id = UUID() + let first = SchemaConnectionState.forConnection(id) + first.setState(.loaded([TestFixtures.makeTableInfo(name: "users")])) + + SchemaConnectionState.removeConnection(id) + let second = SchemaConnectionState.forConnection(id) + + #expect(first !== second) + #expect(second.state == .idle) + + SchemaConnectionState.removeConnection(id) + } +}