Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
102 changes: 86 additions & 16 deletions TablePro/Core/Services/Query/SchemaService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand All @@ -201,6 +204,7 @@ final class SchemaService {
driver: DatabaseDriver,
connection: DatabaseConnection
) async {
let generation = beginLoadGeneration(for: connectionId)
states[connectionId] = .loading
bumpGeneration(connectionId)

Expand All @@ -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
}

Expand All @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bump generation when publishing tables

When a reload replaces the table list with a different list of the same count while routine/schema fetches are still pending, this assignment makes the new tables observable without changing generationToken; SidebarViewModel.filteredTables(from:) and kind caches key only on count, generation, and query, so after the .loading bump they can keep returning the old table rows until auxiliary metadata completes (or indefinitely if it hangs). Bump the generation at the point the loaded table state is published, before awaiting procedures/functions/schemas.

Useful? React with 👍 / 👎.


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
Comment on lines +274 to +276

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't clear state for a newer load

When an older table fetch fails after a newer load has already started (for example the schema/database switch paths invalidate and then reload), this stale branch observes the newer load's .loading state and changes it to .idle. That clears the sidebar spinner and exposes an empty/idle table list even though the current generation is still fetching; stale generations should not mutate states for a different in-flight generation.

Useful? React with 👍 / 👎.

}
return
}
Self.logger.warning(
"[schema] load failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
)
Expand All @@ -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,
Expand All @@ -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<UUID, [String]>,
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
}
}

Expand Down
156 changes: 156 additions & 0 deletions TableProTests/Services/SchemaServiceRoutinesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import Foundation
import TableProPluginKit
import Testing

@testable import TablePro

private final class RoutineMockDriver: DatabaseDriver, @unchecked Sendable {
Expand Down Expand Up @@ -153,6 +154,116 @@ private final class FailingRoutineDriver: DatabaseDriver, @unchecked Sendable {
}
}

private actor AsyncGate {
private var waiters: [CheckedContinuation<Void, Never>] = []
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 {
Expand Down Expand Up @@ -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()
Expand Down
Loading