Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Saving on a MySQL or MariaDB server that starts sessions read-only no longer fails with "Cannot execute statement in a READ ONLY transaction". TablePro marks a transaction read-write before it writes instead of inheriting the server default. Same for PostgreSQL, CockroachDB, and Redshift. (#2009)
- Changing Safe Mode in the connection form now applies to an open connection instead of waiting for a reconnect. (#2009)
- A read-only error now says whether the database server or Safe Mode refused the write. (#2009)

## [0.62.0] - 2026-08-02

### Added
Expand Down
6 changes: 5 additions & 1 deletion Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
// MARK: - Transaction Management

func beginTransaction() async throws {
_ = try await execute(query: "START TRANSACTION")
try await beginTransaction(mode: .serverDefault)
}

func beginTransaction(mode: PluginTransactionAccessMode) async throws {
_ = try await execute(query: mysqlBeginTransactionStatement(mode: mode))
}

// MARK: - Query Execution
Expand Down
6 changes: 6 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLTransactionStatement.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation
import TableProPluginKit

internal func mysqlBeginTransactionStatement(mode: PluginTransactionAccessMode) -> String {
mode == .readWrite ? "START TRANSACTION READ WRITE" : "START TRANSACTION"
}
9 changes: 9 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,15 @@ extension LibPQBackedDriver {
var currentSchema: String? { core.currentSchema }
var supportsSchemas: Bool { true }
var supportsTransactions: Bool { true }

func beginTransaction() async throws {
try await beginTransaction(mode: .serverDefault)
}

func beginTransaction(mode: PluginTransactionAccessMode) async throws {
_ = try await execute(query: postgresBeginTransactionStatement(mode: mode))
}

var serverVersion: String? { core.serverVersion }
var parameterStyle: ParameterStyle { .dollar }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation
import TableProPluginKit

internal func postgresBeginTransactionStatement(mode: PluginTransactionAccessMode) -> String {
mode == .readWrite ? "BEGIN READ WRITE" : "BEGIN"
}
5 changes: 5 additions & 0 deletions Plugins/TableProPluginKit/PluginDatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {

var supportsTransactions: Bool { get }
func beginTransaction() async throws
func beginTransaction(mode: PluginTransactionAccessMode) async throws
func commitTransaction() async throws
func rollbackTransaction() async throws

Expand Down Expand Up @@ -228,6 +229,10 @@ public extension PluginDatabaseDriver {
_ = try await execute(query: "BEGIN")
}

func beginTransaction(mode: PluginTransactionAccessMode) async throws {
try await beginTransaction()
}

func commitTransaction() async throws {
_ = try await execute(query: "COMMIT")
}
Expand Down
6 changes: 6 additions & 0 deletions Plugins/TableProPluginKit/PluginTransactionAccessMode.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

public enum PluginTransactionAccessMode: Sendable, Equatable {
case serverDefault
case readWrite
}
1 change: 1 addition & 0 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,7 @@
MySQLQueryTimeoutStatement.swift,
MySQLSocketTimeout.swift,
MySQLStatementClassification.swift,
MySQLTransactionStatement.swift,
);
target = 5ABCC5A62F43856700EAF3FC /* TableProTests */;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ extension QueryExecutionCoordinator {
) {
parent.currentQueryTask = nil
parent.tabManager.mutate(tabId: tabId) { tab in
tab.execution.errorMessage = error.localizedDescription
tab.execution.errorMessage = DatabaseWriteRejectionDiagnosis.formatted(error)
tab.execution.errorQuery = sql
tab.execution.isExecuting = false
tab.execution.lastExecutedAt = Date()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,12 @@ extension QueryExecutionCoordinator {
}

let useTransaction = driver.supportsTransactions
let transactionKind = OperationKind.worst(of: statements, databaseType: conn.type)

if useTransaction {
try await driver.beginTransaction()
try await driver.beginTransaction(
mode: transactionKind.declaresWrite ? .readWrite : .serverDefault
)
}

@MainActor func rollbackAndResetState() async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import AppKit
import Foundation
import os
import TableProPluginKit

private let discardLogger = Logger(subsystem: "com.TablePro", category: "RowEditingCoordinator+Discard")

Expand All @@ -14,12 +15,13 @@ extension RowEditingCoordinator {

func executeSidebarChanges(statements: [ParameterizedStatement]) async throws {
let sqlPreview = statements.map(\.sql).joined(separator: "\n")
let kind = OperationKind.from(QueryClassifier.classifyTier(sqlPreview, databaseType: parent.connection.type))
let decision = await ExecutionGateProvider.shared.authorize(
OperationRequest(
connectionId: parent.connectionId,
databaseType: parent.connection.type,
sql: sqlPreview,
kind: OperationKind.from(QueryClassifier.classifyTier(sqlPreview, databaseType: parent.connection.type)),
kind: kind,
caller: .userInterface,
capabilities: .interactiveUser,
operationDescription: String(localized: "Save Sidebar Changes")
Expand All @@ -36,7 +38,7 @@ extension RowEditingCoordinator {
let useTransaction = driver.supportsTransactions

if useTransaction {
try await driver.beginTransaction()
try await driver.beginTransaction(mode: kind.declaresWrite ? .readWrite : .serverDefault)
}

do {
Expand Down
13 changes: 10 additions & 3 deletions TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import Foundation
import os
import SwiftUI
import TableProPluginKit

private let saveChangesLogger = Logger(subsystem: "com.TablePro", category: "RowEditingCoordinator")

Expand Down Expand Up @@ -187,7 +188,7 @@ extension RowEditingCoordinator {
let useTransaction = driver.supportsTransactions

if useTransaction {
try await driver.beginTransaction()
try await driver.beginTransaction(mode: .readWrite)
}

do {
Expand Down Expand Up @@ -292,15 +293,21 @@ extension RowEditingCoordinator {
errorMessage: error.localizedDescription
)

let diagnosis = DatabaseWriteRejectionDiagnosis.classify(error)

if let index = parent.tabManager.selectedTabIndex {
parent.tabManager.mutate(at: index) {
$0.execution.errorMessage = String(format: String(localized: "Save failed: %@"), error.localizedDescription)
$0.execution.errorMessage = String(
format: String(localized: "Save failed: %@"),
DatabaseWriteRejectionDiagnosis.formatted(error)
)
}
}

AlertHelper.showErrorSheet(
title: String(localized: "Save Failed"),
message: error.localizedDescription,
message: diagnosis?.errorDescription ?? error.localizedDescription,
recoverySuggestion: diagnosis?.recoverySuggestion,
window: parent.contentWindow
)

Expand Down
6 changes: 6 additions & 0 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ protocol DatabaseDriver: AnyObject, Sendable {
/// Begin a transaction
func beginTransaction() async throws

func beginTransaction(mode: PluginTransactionAccessMode) async throws

/// Commit the current transaction
func commitTransaction() async throws

Expand Down Expand Up @@ -236,6 +238,10 @@ extension DatabaseDriver {

var queryBuildingPluginDriver: (any PluginDatabaseDriver)? { nil }

func beginTransaction(mode: PluginTransactionAccessMode) async throws {
try await beginTransaction()
}

func quoteIdentifier(_ name: String) -> String {
SQLEscaping.quoteIdentifier(name)
}
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Database/DatabaseManager+Principals.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ extension DatabaseManager {
) async throws {
let useTransaction = driver.supportsTransactions && rollsBack
if useTransaction {
try await driver.beginTransaction()
try await driver.beginTransaction(mode: .readWrite)
}

var appliedCount = 0
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Database/DatabaseManager+Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ extension DatabaseManager {
let useTransaction = driver.supportsTransactions

if useTransaction {
try await driver.beginTransaction()
try await driver.beginTransaction(mode: schemaKind.declaresWrite ? .readWrite : .serverDefault)
}

do {
Expand Down
17 changes: 17 additions & 0 deletions TablePro/Core/Database/DatabaseManager+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,23 @@ extension DatabaseManager {
setSession(session, for: sessionId)
}

func observeConnectionUpdates() {
connectionUpdatedCancellable = AppEvents.shared.connectionUpdated
.receive(on: RunLoop.main)
.sink { [weak self] connectionId in
self?.reconcileSafeModeLevel(for: connectionId)
}
}

func reconcileSafeModeLevel(for connectionId: UUID?) {
let targetIds = connectionId.map { [$0] } ?? Array(activeSessions.keys)
for id in targetIds {
guard activeSessions[id] != nil,
let stored = connectionStorage.loadConnection(id: id) else { continue }
setSafeModeLevel(stored.safeModeLevel, for: id)
}
}

func setSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) {
guard var session = activeSessions[connectionId] else { return }
guard session.safeModeLevel != level || session.connection.safeModeLevel != level else { return }
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Created by Ngo Quoc Dat on 16/12/25.
//

import Combine
import Foundation
import Observation
import os
Expand Down Expand Up @@ -58,6 +59,8 @@ final class DatabaseManager {
/// and the wake-from-sleep handler fire for the same connection.
@ObservationIgnored internal var recoveringConnectionIds = Set<UUID>()

@ObservationIgnored internal var connectionUpdatedCancellable: AnyCancellable?

@ObservationIgnored internal let ensureConnectedDedup = OnceTask<UUID, Void>()

/// Generation token per connection. A cancelled or superseded attempt keeps running
Expand Down Expand Up @@ -119,5 +122,6 @@ final class DatabaseManager {
self.connectionStorage = connectionStorage
self.appSettingsStorage = appSettingsStorage
self.pluginManager = pluginManager
observeConnectionUpdates()
}
}
60 changes: 60 additions & 0 deletions TablePro/Core/Database/DatabaseWriteRejectionDiagnosis.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//
// DatabaseWriteRejectionDiagnosis.swift
// TablePro
//

import Foundation
import TableProPluginKit

internal enum DatabaseWriteRejectionDiagnosis: LocalizedError, Equatable {
case serverEnforcedReadOnly(serverMessage: String)

private static let readOnlyTransactionSqlState = "25006"
private static let mysqlReadOnlyServerCodes: Set<Int> = [1_290, 1_836, 1_874]

static func classify(_ error: Error) -> DatabaseWriteRejectionDiagnosis? {
guard let driverError = error as? any PluginDriverError else { return nil }

if driverError.pluginSqlState == readOnlyTransactionSqlState {
return .serverEnforcedReadOnly(serverMessage: driverError.pluginErrorMessage)
}

if let code = driverError.pluginErrorCode, mysqlReadOnlyServerCodes.contains(code) {
return .serverEnforcedReadOnly(serverMessage: driverError.pluginErrorMessage)
}

return nil
}

static func formatted(_ error: Error) -> String {
guard let diagnosis = classify(error) else { return error.localizedDescription }
return [diagnosis.errorDescription, diagnosis.recoverySuggestion]
.compactMap { $0 }
.joined(separator: "\n\n")
}

var serverMessage: String {
switch self {
case .serverEnforcedReadOnly(let message): return message
}
}

var errorDescription: String? {
String(localized: "The database server rejected this write because the connection is read-only.")
}

var recoverySuggestion: String? {
String(
format: String(
localized: """
The database server enforced this, not TablePro's Safe Mode. \
You are most likely connected to a read replica, or the server sets new transactions to read-only. \
Connect to the primary server to write.

Server response: %@
"""
),
serverMessage
)
}
}
3 changes: 2 additions & 1 deletion TablePro/Core/Database/TriggerEditing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import Combine
import Foundation
import os
import TableProPluginKit

enum TriggerEditingError: LocalizedError {
case notConnected
Expand Down Expand Up @@ -121,7 +122,7 @@ enum TriggerEditing {
}

static func runInTransaction(driver: DatabaseDriver, dropSQL: String?, sql: String) async throws {
try await driver.beginTransaction()
try await driver.beginTransaction(mode: .readWrite)
do {
if let dropSQL { _ = try await driver.execute(query: dropSQL) }
_ = try await driver.execute(query: sql)
Expand Down
2 changes: 1 addition & 1 deletion TablePro/Core/Plugins/ImportDataSinkAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable {
}

func beginTransaction() async throws {
try await driver.beginTransaction()
try await driver.beginTransaction(mode: .readWrite)
}

func commitTransaction() async throws {
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Core/Plugins/PluginDriverAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
try await pluginDriver.beginTransaction()
}

func beginTransaction(mode: PluginTransactionAccessMode) async throws {
try await pluginDriver.beginTransaction(mode: mode)
}

func commitTransaction() async throws {
try await pluginDriver.commitTransaction()
}
Expand Down
4 changes: 3 additions & 1 deletion TablePro/Core/Services/Execution/DefaultExecutionGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ internal actor DefaultExecutionGate: ExecutionGate {
}

if level.blocksAllWrites, effectiveWrite {
return .denied(reason: String(localized: "Cannot execute write queries: connection is read-only"))
return .denied(reason: String(
localized: "Cannot execute write queries: TablePro's Safe Mode is set to read-only for this connection"
))
}

let isMetadataRead = request.kind == .metadataRead
Expand Down
5 changes: 5 additions & 0 deletions TablePro/Core/Services/Execution/OperationKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//

import Foundation
import TableProPluginKit

internal enum OperationKind: Sendable, Equatable {
case readQuery
Expand All @@ -29,6 +30,10 @@ internal extension OperationKind {
self == .destructiveQuery
}

var transactionAccessMode: PluginTransactionAccessMode {
declaresWrite ? .readWrite : .serverDefault
}

static func from(_ tier: QueryTier) -> OperationKind {
switch tier {
case .safe: return .readQuery
Expand Down
Loading
Loading