diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a738e308..3d8349b4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Approving a Safe Mode (Full) confirmation running that table's query in a tab opened from a link meanwhile. - Restored table tab that never loads when another tab was opened before its window came forward. - Save writing a table's query into the SQL file whose tab the table was opened over. +- Closing a database entry or connection, or disconnecting, rolling back an open transaction without asking. ## [0.77.2] - 2026-10-05 diff --git a/TablePro/Core/Database/DatabaseManager+OpenTransactions.swift b/TablePro/Core/Database/DatabaseManager+OpenTransactions.swift new file mode 100644 index 000000000..2da91e3d7 --- /dev/null +++ b/TablePro/Core/Database/DatabaseManager+OpenTransactions.swift @@ -0,0 +1,64 @@ +// +// DatabaseManager+OpenTransactions.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension DatabaseManager { + /// A connection running a turn is skipped, because asking it waits for that turn. One that has + /// not answered within a second counts as holding nothing, so a close never hangs on a busy or + /// dead server. An aborted transaction counts: a savepoint taken before the failure can still + /// bring its earlier work back to a commit. + internal func databasesHoldingTransaction( + for connectionId: UUID, + among databases: Set? = nil + ) async -> [String] { + guard let session = activeSessions[connectionId] else { return [] } + var candidates: [(database: String, driver: DatabaseDriver)] = [] + if let driver = session.driver, !sessionDriverGate.isHeld(browsedGateKey(for: connectionId)) { + candidates.append((session.resolvedBrowseDatabase, driver)) + } + for database in sessionLanes.parkedDatabases(for: connectionId).sorted() { + let key = SessionDriverGate.Key(connectionId: connectionId, database: database) + guard let driver = sessionLanes.parkedDriver(for: connectionId, database: database), + !sessionDriverGate.isHeld(key) + else { continue } + candidates.append((database, driver)) + } + let reads = candidates + .filter { databases?.contains($0.database) ?? true } + .map { candidate in + (database: candidate.database, state: Task { await Self.boundedTransactionState(of: candidate.driver) }) + } + var holding: [String] = [] + for read in reads { + let state = await read.state.value + if state == .inTransaction || state == .abortedTransaction { + holding.append(read.database) + } + } + return holding + } + + /// A task group is no bound here: it waits for its child, and the read queues behind any + /// statement on the connection's serial queue, which ignores cancellation. The late answer is + /// dropped, never cancelled: a driver's cancel can stop the other work queued on the connection. + private static func boundedTransactionState(of driver: DatabaseDriver) async -> PluginSessionTransactionState { + let gate = ConnectionSingleResumeGate() + Task { + gate.resume(with: .success(await driver.heldSessionTransactionState())) + } + let deadline = Task.detached { + do { + try await Task.sleep(for: .seconds(1)) + } catch { + return + } + gate.resume(with: .success(.unknown)) + } + defer { deadline.cancel() } + return (try? await gate.wait()) ?? .unknown + } +} diff --git a/TablePro/Core/Services/Infrastructure/ConnectionCloseAction.swift b/TablePro/Core/Services/Infrastructure/ConnectionCloseAction.swift index c164db8dd..f7ebfbab3 100644 --- a/TablePro/Core/Services/Infrastructure/ConnectionCloseAction.swift +++ b/TablePro/Core/Services/Infrastructure/ConnectionCloseAction.swift @@ -21,6 +21,7 @@ import Foundation internal enum ConnectionCloseAction { internal enum Decision: Equatable { case closeImmediately + case confirmEndingTransaction case confirmUnsavedWork } @@ -40,9 +41,26 @@ internal enum ConnectionCloseAction { return !targets.isEmpty } - internal static func decision(hasSession: Bool, hasUnsavedWork: Bool) -> Decision { - guard hasSession, hasUnsavedWork else { return .closeImmediately } - return .confirmUnsavedWork + /// The transaction is asked about before unsaved work, whose Save writes at once: a Cancel on the + /// transaction after it would keep the connection open with that write already made. + internal static func decision(hasSession: Bool, holdsTransaction: Bool, hasUnsavedWork: Bool) -> Decision { + guard hasSession else { return .closeImmediately } + if holdsTransaction { return .confirmEndingTransaction } + return hasUnsavedWork ? .confirmUnsavedWork : .closeImmediately + } + + internal static func transactionMessage(for databases: [String]) -> String? { + guard let first = databases.first else { return nil } + guard databases.count > 1 else { + return String( + format: String(localized: "The database “%@” has an open transaction. Closing rolls it back and discards its uncommitted changes."), + first + ) + } + return String( + format: String(localized: "These databases have open transactions: %@. Closing rolls them back and discards their uncommitted changes."), + ListFormatter.localizedString(byJoining: databases.map { "“\($0)”" }) + ) } internal static func close(connectionId: UUID) async { @@ -51,21 +69,42 @@ internal enum ConnectionCloseAction { /// reported the connection as safe to close over work nobody had been shown. let coordinators = WindowManager.shared.coordinators(for: connectionId) let coordinator = coordinators.first ?? WindowManager.shared.coordinator(for: connectionId) - let decision = decision( - hasSession: coordinator != nil, - hasUnsavedWork: coordinators.contains { $0.hasAnyUnsavedWork() } + /// Read at each decision, not once: the window stays editable while the transaction state + /// and the alert are awaited. + let hasUnsavedWork = { + coordinators.contains { $0.hasAnyUnsavedWork() } || (coordinators.isEmpty && coordinator?.hasAnyUnsavedWork() == true) - ) - guard decision == .confirmUnsavedWork else { - WindowManager.shared.closeWindow(for: connectionId) - return } + let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: connectionId) + var decision = decision( + hasSession: coordinator != nil, + holdsTransaction: !holding.isEmpty, + hasUnsavedWork: hasUnsavedWork() + ) /// Shown, then asked. A data-loss alert over a connection the user cannot see names work /// they have no way to look at before answering. Revealing switches the window to it, so an /// answer that closes nothing puts the user back where they were: a close that leaves them /// on another connection, with its entry still in the strip, reads as a switch. let wasShowing = WindowManager.shared.shownConnection(besides: connectionId) + if decision == .confirmEndingTransaction, let coordinator, let message = transactionMessage(for: holding) { + let confirmed = await AlertHelper.confirmDestructive( + title: String(format: String(localized: "Close the connection “%@”?"), coordinator.connection.name), + message: message, + confirmButton: String(localized: "Close"), + window: reveal(connectionId: connectionId) + ) + guard confirmed else { + WindowManager.shared.show(wasShowing, inWindowHosting: connectionId) + return + } + decision = Self.decision(hasSession: true, holdsTransaction: false, hasUnsavedWork: hasUnsavedWork()) + } + guard decision == .confirmUnsavedWork else { + WindowManager.shared.closeWindow(for: connectionId) + return + } + let presentingWindow = reveal(connectionId: connectionId) switch await AlertHelper.confirmSaveChanges( message: String(localized: "Your changes will be lost if you don't save them."), diff --git a/TablePro/Core/Services/Infrastructure/ConnectionDisconnectAction.swift b/TablePro/Core/Services/Infrastructure/ConnectionDisconnectAction.swift index 73f3bcd30..29d6683a5 100644 --- a/TablePro/Core/Services/Infrastructure/ConnectionDisconnectAction.swift +++ b/TablePro/Core/Services/Infrastructure/ConnectionDisconnectAction.swift @@ -16,7 +16,7 @@ internal enum ConnectionDisconnectAction { connectionName: String, presentingWindow: NSWindow? ) async { - if let message = confirmationMessage(for: connectionId) { + if let message = await confirmationMessage(for: connectionId) { let confirmed = await AlertHelper.confirmDestructive( title: String(format: String(localized: "Disconnect from “%@”?"), connectionName), message: message, @@ -30,7 +30,11 @@ internal enum ConnectionDisconnectAction { /// Nil means disconnect without asking. Losing work the user cannot get back is worth an alert; /// ending a session they asked to end is not, which is why a clean connection never sees one. - private static func confirmationMessage(for connectionId: UUID) -> String? { + private static func confirmationMessage(for connectionId: UUID) async -> String? { + let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: connectionId) + if let message = transactionMessage(for: holding) { + return message + } if MainContentCoordinator.hasUnsavedWork(forConnection: connectionId) { return String(localized: "Unsaved changes will be lost.") } @@ -39,4 +43,18 @@ internal enum ConnectionDisconnectAction { } return nil } + + internal static func transactionMessage(for databases: [String]) -> String? { + guard let first = databases.first else { return nil } + guard databases.count > 1 else { + return String( + format: String(localized: "The database “%@” has an open transaction. Disconnecting rolls it back and discards its uncommitted changes."), + first + ) + } + return String( + format: String(localized: "These databases have open transactions: %@. Disconnecting rolls them back and discards their uncommitted changes."), + ListFormatter.localizedString(byJoining: databases.map { "“\($0)”" }) + ) + } } diff --git a/TablePro/Core/Services/Infrastructure/WorkspaceCloseAction.swift b/TablePro/Core/Services/Infrastructure/WorkspaceCloseAction.swift index 191152ffb..0da73a904 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspaceCloseAction.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspaceCloseAction.swift @@ -77,12 +77,17 @@ internal enum WorkspaceCloseAction { let coordinators = hostedWorkspaces.compactMap { $0.sessionState?.coordinator } let coordinator = coordinators.first - let victims = coordinators.flatMap { tabs(in: workspace.container, of: $0) } /// Where the user was before the alert. Confirming reveals the work at risk, which switches /// the window to that connection and selects one of the tabs, and an answer that closes /// nothing has to put all of that back: leaving the user on a connection they did not ask /// for, with the entry still listed, is a close that reads as a switch. let wasShowing = WindowManager.shared.shownConnection(besides: workspace.connectionId) + guard await confirmEndingTransaction(in: workspace) else { + Self.logger.info("close cancelled at the transaction prompt container=\(workspace.container, privacy: .public)") + return + } + /// Taken after the transaction prompt: the tabs stay editable while it is answered. + let victims = coordinators.flatMap { tabs(in: workspace.container, of: $0) } guard let closable = await confirm(victims, across: coordinators, revealing: workspace) else { WindowManager.shared.show(wasShowing, inWindowHosting: workspace.connectionId) Self.logger.info("close cancelled at the save prompt container=\(workspace.container, privacy: .public)") @@ -226,6 +231,29 @@ internal enum WorkspaceCloseAction { return closable } + /// Asked before the unsaved-work prompt, whose Save writes at once, so a Cancel here leaves + /// everything as it was. Nothing is revealed: the alert names the database, and the transaction + /// is not in any one tab. Only a database with its own connection loses a transaction on close. + private static func confirmEndingTransaction(in workspace: WorkspaceID) async -> Bool { + let manager = DatabaseManager.shared + guard let session = manager.session(for: workspace.connectionId), + manager.usesDatabaseLanes(session) + else { return true } + let holding = await manager.databasesHoldingTransaction( + for: workspace.connectionId, + among: [workspace.container] + ) + guard !holding.isEmpty else { return true } + return await AlertHelper.confirmDestructive( + title: String(format: String(localized: "Close the database “%@”?"), workspace.container), + message: String( + localized: "This database has an open transaction. Closing it rolls the transaction back and discards its uncommitted changes." + ), + confirmButton: String(localized: "Close"), + window: WindowManager.shared.window(for: workspace.connectionId) + ) + } + /// Leaves the container before it stops being listed, and only when it is the one being browsed. /// Every other entry already shows the container it names, and moving the cursor for them would /// switch the database out from under work the user did not touch. diff --git a/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift index f15681f4c..9cea0d224 100644 --- a/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift +++ b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift @@ -107,19 +107,6 @@ extension MainContentCommandActions { } } - /// For the paths that close everything whatever the answer: a window closing, a connection - /// closing, a disconnect. They have nowhere to leave a tab open, so a save that could not take - /// every victim stops them, the way a failed apply of staged ALTERs always did. Answering true - /// on a partial save would close exactly the tabs the save refused. - func confirmDiscardingUnsavedWork(victims: [QueryTab] = []) async -> Bool { - switch await resolveUnsavedWork(in: victims) { - case .cancel: - return false - case .close(let closable): - return closable.isSuperset(of: Set(victims.map(\.id))) - } - } - /// Save cannot reach a tab that is not on screen for grid edits, staged principals or a table /// draft, so the alert says what will happen to those rather than promising a save it cannot /// make: they stay open, and everything else closes. diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 2c7af0e48..f9b2ebc6b 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -731,10 +731,7 @@ final class MainContentCommandActions: ObservableObject { closeTab(id: selected.id) return } - Task { - guard await confirmDiscardingUnsavedWork() else { return } - WindowManager.shared.closeWindow(for: connectionId) - } + Task { await ConnectionCloseAction.close(connectionId: connectionId) } } /// The single close primitive. `asBatchSurvivor` is `nil` for a lone close gesture, which lets diff --git a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift index fcfa01ee5..57c7f0352 100644 --- a/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift +++ b/TableProTests/Core/Autocomplete/SQLSchemaProviderTests.swift @@ -60,11 +60,17 @@ final class MockDatabaseDriver: DatabaseDriver, SchemaSwitchable, @unchecked Sen } var sessionTransactionStateToReturn: PluginSessionTransactionState = .unknown + var sessionTransactionStateDelaySeconds: Double = 0 + var sessionTransactionStateReadWasCancelled = false /// libpq reports no transaction state once a check has found the socket closed. var pingFailureForgetsTransactionState = false func sessionTransactionState() async -> PluginSessionTransactionState { - sessionTransactionStateToReturn + if sessionTransactionStateDelaySeconds > 0 { + try? await Task.sleep(nanoseconds: UInt64(sessionTransactionStateDelaySeconds * 1_000_000_000)) + if Task.isCancelled { sessionTransactionStateReadWasCancelled = true } + } + return sessionTransactionStateToReturn } init(connection: DatabaseConnection = TestFixtures.makeConnection()) { diff --git a/TableProTests/Core/Database/SessionLanesTests.swift b/TableProTests/Core/Database/SessionLanesTests.swift index 46ba3c884..f6a625805 100644 --- a/TableProTests/Core/Database/SessionLanesTests.swift +++ b/TableProTests/Core/Database/SessionLanesTests.swift @@ -426,6 +426,97 @@ struct SessionLanesTests { #expect(AppSettingsStorage.shared.loadLastSchema(for: harness.connection.id) == "logs_schema") } + + @Test("An open transaction is reported for the browsed database and for every parked one") + func openTransactionsAreReportedOnEveryLane() async throws { + let harness = makeHarness() + defer { cleanUp(harness) } + try await DatabaseManager.shared.switchDatabase(to: "logs", for: harness.connection.id, persist: false) + let logs = try #require(harness.opener.opened["logs"]) + harness.home.sessionTransactionStateToReturn = .inTransaction + logs.sessionTransactionStateToReturn = .abortedTransaction + + let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: harness.connection.id) + let parkedOnly = await DatabaseManager.shared.databasesHoldingTransaction( + for: harness.connection.id, + among: ["app"] + ) + + #expect(holding.sorted() == ["app", "logs"]) + #expect(parkedOnly == ["app"]) + } + + /// A lock or an unknown state loses no work, so neither is worth asking about. + @Test( + "A connection without an open transaction is not reported", + arguments: [PluginSessionTransactionState.idle, .holdsSessionLocks, .unknown] + ) + func onlyAnOpenTransactionIsReported(_ state: PluginSessionTransactionState) async throws { + let harness = makeHarness() + defer { cleanUp(harness) } + try await DatabaseManager.shared.switchDatabase(to: "logs", for: harness.connection.id, persist: false) + let logs = try #require(harness.opener.opened["logs"]) + harness.home.sessionTransactionStateToReturn = state + logs.sessionTransactionStateToReturn = state + + let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: harness.connection.id) + + #expect(holding.isEmpty) + } + + @Test("A connection running a turn is not asked, because asking would wait for the turn") + func connectionRunningATurnIsSkipped() async throws { + let harness = makeHarness() + defer { cleanUp(harness) } + harness.home.sessionTransactionStateToReturn = .inTransaction + let release = AsyncLatch() + let acquired = AsyncLatch() + let holder = Task { @MainActor in + try await DatabaseManager.shared.sessionDriverGate.withExclusiveAccess( + SessionDriverGate.Key(connectionId: harness.connection.id, database: "app") + ) { + acquired.open() + await release.wait() + } + } + await acquired.wait() + + let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: harness.connection.id) + + release.open() + try await holder.value + #expect(holding.isEmpty) + } + + @Test("A connection that does not answer counts as holding nothing once the bound passes") + func silentConnectionIsBounded() async { + let harness = makeHarness() + defer { cleanUp(harness) } + harness.home.sessionTransactionStateToReturn = .inTransaction + harness.home.sessionTransactionStateDelaySeconds = 30 + let clock = ContinuousClock() + let started = clock.now + + let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: harness.connection.id) + + #expect(holding.isEmpty) + #expect(clock.now - started < .seconds(5)) + } + + /// A driver's cancel can reach past the read: FreeTDS stops every call queued on the connection. + @Test("A read that misses the bound is left to finish, not cancelled") + func lateReadIsNotCancelled() async throws { + let harness = makeHarness() + defer { cleanUp(harness) } + harness.home.sessionTransactionStateToReturn = .inTransaction + harness.home.sessionTransactionStateDelaySeconds = 1.5 + + let holding = await DatabaseManager.shared.databasesHoldingTransaction(for: harness.connection.id) + try await Task.sleep(for: .seconds(1)) + + #expect(holding.isEmpty) + #expect(!harness.home.sessionTransactionStateReadWasCancelled) + } } @MainActor diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionCloseActionTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionCloseActionTests.swift index b601e435d..82b2d215b 100644 --- a/TableProTests/Core/Services/Infrastructure/ConnectionCloseActionTests.swift +++ b/TableProTests/Core/Services/Infrastructure/ConnectionCloseActionTests.swift @@ -15,7 +15,7 @@ struct ConnectionCloseActionTests { @Test("A connection with no session closes without asking") func sessionlessClosesImmediately() { #expect( - ConnectionCloseAction.decision(hasSession: false, hasUnsavedWork: false) == .closeImmediately + ConnectionCloseAction.decision(hasSession: false, holdsTransaction: false, hasUnsavedWork: false) == .closeImmediately ) } @@ -24,21 +24,68 @@ struct ConnectionCloseActionTests { @Test("Unsaved work without a session still closes without asking") func sessionlessIgnoresUnsavedWork() { #expect( - ConnectionCloseAction.decision(hasSession: false, hasUnsavedWork: true) == .closeImmediately + ConnectionCloseAction.decision(hasSession: false, holdsTransaction: false, hasUnsavedWork: true) == .closeImmediately ) } @Test("A clean connection closes without asking") func cleanSessionClosesImmediately() { #expect( - ConnectionCloseAction.decision(hasSession: true, hasUnsavedWork: false) == .closeImmediately + ConnectionCloseAction.decision(hasSession: true, holdsTransaction: false, hasUnsavedWork: false) == .closeImmediately ) } @Test("A connection with unsaved work asks first") func unsavedWorkIsConfirmed() { #expect( - ConnectionCloseAction.decision(hasSession: true, hasUnsavedWork: true) == .confirmUnsavedWork + ConnectionCloseAction.decision(hasSession: true, holdsTransaction: false, hasUnsavedWork: true) == .confirmUnsavedWork ) } + + /// Closing ends the session connection, and the server rolls back what it was holding. + @Test("A connection holding an open transaction asks first") + func openTransactionIsConfirmed() { + #expect( + ConnectionCloseAction.decision(hasSession: true, holdsTransaction: true, hasUnsavedWork: false) + == .confirmEndingTransaction + ) + } + + /// Save on the unsaved-work alert writes at once, so the transaction has to be asked about + /// while a Cancel can still leave everything as it was. + @Test("An open transaction is asked about before unsaved work") + func openTransactionComesBeforeUnsavedWork() { + #expect( + ConnectionCloseAction.decision(hasSession: true, holdsTransaction: true, hasUnsavedWork: true) + == .confirmEndingTransaction + ) + } + + @Test("A transaction reported without a session still closes without asking") + func sessionlessIgnoresTransaction() { + #expect( + ConnectionCloseAction.decision(hasSession: false, holdsTransaction: true, hasUnsavedWork: true) + == .closeImmediately + ) + } + + @Test("No open transaction adds nothing to the close prompt") + func noTransactionNoMessage() { + #expect(ConnectionCloseAction.transactionMessage(for: []) == nil) + } + + @Test("One open transaction names its database") + func oneTransactionNamesTheDatabase() throws { + let message = try #require(ConnectionCloseAction.transactionMessage(for: ["app"])) + #expect(message.contains("“app”")) + #expect(message.contains("Closing rolls it back")) + } + + @Test("Several open transactions list every database") + func severalTransactionsListEveryDatabase() throws { + let message = try #require(ConnectionCloseAction.transactionMessage(for: ["app", "logs"])) + #expect(message.contains("“app”")) + #expect(message.contains("“logs”")) + #expect(message.contains("Closing rolls them back")) + } } diff --git a/TableProTests/Core/Services/Infrastructure/ConnectionDisconnectActionTests.swift b/TableProTests/Core/Services/Infrastructure/ConnectionDisconnectActionTests.swift new file mode 100644 index 000000000..c98976663 --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/ConnectionDisconnectActionTests.swift @@ -0,0 +1,31 @@ +// +// ConnectionDisconnectActionTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct ConnectionDisconnectActionTests { + @Test("No open transaction adds nothing to the disconnect prompt") + func noTransactionNoMessage() { + #expect(ConnectionDisconnectAction.transactionMessage(for: []) == nil) + } + + @Test("One open transaction names its database") + func oneTransactionNamesTheDatabase() throws { + let message = try #require(ConnectionDisconnectAction.transactionMessage(for: ["app"])) + #expect(message.contains("“app”")) + #expect(message.contains("Disconnecting rolls it back")) + } + + @Test("Several open transactions list every database") + func severalTransactionsListEveryDatabase() throws { + let message = try #require(ConnectionDisconnectAction.transactionMessage(for: ["app", "logs"])) + #expect(message.contains("“app”")) + #expect(message.contains("“logs”")) + #expect(message.contains("Disconnecting rolls them back")) + } +} diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index def1b6c2b..c6c944542 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -122,7 +122,7 @@ A `CREATE INDEX CONCURRENTLY` or `REINDEX CONCURRENTLY` that fails or is cancell ## Cross-database tabs -PostgreSQL has no in-place `USE`, so every database browsed from the [connections strip](/features/workspace-rail) gets a server connection of its own. Moving between two database entries switches between those connections without reconnecting, and each keeps its open transaction, temp tables, and `SET` values until its entry is closed or the connection ends. A tab runs on the connection of the database it is bound to. +PostgreSQL has no in-place `USE`, so every database browsed from the [connections strip](/features/workspace-rail) gets a server connection of its own. Moving between two database entries switches between those connections without reconnecting, and each keeps its open transaction, temp tables, and `SET` values until its entry is closed or the connection ends. Closing the entry or the connection, or disconnecting, asks first when that rolls back an open transaction. A tab runs on the connection of the database it is bound to. A tab bound to a database that has no entry runs on a [metadata connection](/connections/connection-form#connection-health) instead. That one is shared with the Structure tab and closes after 10 minutes idle, so open the database from the strip before starting a transaction or a `CREATE TEMP TABLE` there. A database connection that died while idle is reopened on its next use; if it held a transaction, that statement is refused once with a message saying the server rolled it back. Binding itself is on [Tabs](/features/tabs#where-a-tab-points). @@ -145,7 +145,7 @@ New connections default to **Preferred** (libpq `sslmode=prefer`): TLS first, pl ## Limitations - Columns cannot be reordered. The structure editor adds, renames, retypes, and drops; changing the order of existing columns means recreating the table. -- Session state belongs to one database. A temp table or an open transaction made on `app` is not visible from a tab on `logs`, and closing the `app` entry ends it. +- Session state belongs to one database. A temp table or an open transaction made on `app` is not visible from a tab on `logs`, and closing the `app` entry ends it, after asking first when a transaction is open. - Backup and restore need `pg_dump` and `pg_restore` on your Mac. Neither is bundled; install them with Homebrew. `pg_dump` 15 and later refuse PostgreSQL 9.1, so back one up with `pg_dump` 14 or earlier. - Before PostgreSQL 12, the [`run_maintenance`](/external-api/mcp-tools) tool's `REINDEX` with no `table` runs without `CONCURRENTLY` and rebuilds the system catalog indexes as well. While an index rebuilds, writes to its table wait, and so do reads that use that index. Run it when the database is quiet. diff --git a/docs/features/workspace-rail.mdx b/docs/features/workspace-rail.mdx index c9725579d..cc331141f 100644 --- a/docs/features/workspace-rail.mdx +++ b/docs/features/workspace-rail.mdx @@ -46,7 +46,7 @@ Closing an entry's last tab leaves the entry where it is: tabs and entries close Between two connections, the window switches connection and returns you to the tab you last used there; between two databases of one connection, the sidebar moves and the tabs stay. An entry with nothing open moves the sidebar only, and clicking the entry you are in does nothing. No tab is ever closed or retargeted by a switch: each one keeps querying the database it was opened against. -On PostgreSQL, Redshift, and CockroachDB every database entry keeps its own server connection, so moving between two entries reconnects nothing and leaves each database's open transaction and temp tables where they were. Closing an entry closes its connection. +On PostgreSQL, Redshift, and CockroachDB every database entry keeps its own server connection, so moving between two entries reconnects nothing and leaves each database's open transaction and temp tables where they were. Closing an entry closes its connection and rolls back a transaction still open there, so it asks first when there is one. The strip takes the keyboard too. Click into it, then use the arrow keys to move the highlight. Type the start of a connection, database, or schema name to jump to it, and press `Return` to open the entry. @@ -66,7 +66,7 @@ Right-click an entry and choose **Close Database “\”** to close that **Close Connection “\”**, below it, closes the whole connection: every tab across every database it has open, its session, and all of its entries. The window closes too if that was its last connection, and **File > Close Connection** does the same for the connection on screen. A connection down to one entry has nothing else left, so closing that entry closes it. Nothing is lost for good: reopen the connection, from the connection list or **Database > Switch Connection…**, and its tabs come back as they were. -**Disconnect**, on the same menu while the connection is live, ends the session and keeps everything else. No window closes, no entry disappears, and the tabs are saved first; the connection shows a Reconnect pane in their place until **Database > Reconnect**, or the pane's own button, brings both back. **Database > Disconnect** does the same for the connection on screen, asking first when a query is running or changes are unsaved. Nothing reconnects on its own, and a connection you disconnected is not reopened at the next launch. +**Disconnect**, on the same menu while the connection is live, ends the session and keeps everything else. No window closes, no entry disappears, and the tabs are saved first; the connection shows a Reconnect pane in their place until **Database > Reconnect**, or the pane's own button, brings both back. **Database > Disconnect** does the same for the connection on screen, asking first when a transaction is open, a query is running, or changes are unsaved. Nothing reconnects on its own, and a connection you disconnected is not reopened at the next launch. ## Reordering