From a764ef6fcb6a7febb18a0ef08e85a25075f4331f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Mon, 25 May 2026 18:27:33 +0700 Subject: [PATCH 1/6] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2c9051166..9c535254a 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,4 @@ fix-1322-plugin-abi-and-registry-overhaul.diff # Issue analysis blueprints (local only) .analysis/ +.docs/ From bc530fd9b063dab48a44295d79403a48a8d1ddb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Mon, 25 May 2026 19:25:09 +0700 Subject: [PATCH 2/6] Update CLAUDE.md --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 027755123..f7c9d6caa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,6 +168,7 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. | Tab state | JSON persistence | `TabPersistenceService` / `TabStateStorage` | | Filter presets | UserDefaults | `FilterSettingsStorage` | | Per-table filters | UserDefaults | `FilterSettingsStorage` (saves `appliedFilters` only) | +| Favorite tables | UserDefaults | `FavoriteTablesStorage` (global, by table name) | ### Logging & Debugging From 97c7f1175275837eb0f0032ce67f47c10818b46d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 27 May 2026 20:44:04 +0700 Subject: [PATCH 3/6] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9c535254a..1dd4bc8e4 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,4 @@ fix-1322-plugin-abi-and-registry-overhaul.diff # Issue analysis blueprints (local only) .analysis/ .docs/ +Local.xcconfig From 90ac808791fb02d3a1b89f21343869c11c984dab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 27 May 2026 21:59:27 +0700 Subject: [PATCH 4/6] fix(connections): auto-persist safe mode level as connection default --- CHANGELOG.md | 1 + .../Database/DatabaseManager+Sessions.swift | 13 +- TablePro/Core/Storage/ConnectionStorage.swift | 32 ++++ .../Core/Storage/SafeModeMigrationTests.swift | 164 +++++++++++++++++- 4 files changed, 199 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b66a87740..3166125be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Changing Safe Mode from the toolbar now saves that level as the connection default, so disconnecting and reconnecting keeps the same protection. - Toolbar customizations now persist after closing and reopening a session window. (#1455) - Pasting rows with commas in a cell now keeps each value in its own column and preserves NULL vs the literal text "NULL". - BigQuery: switching to another table loads its data immediately instead of leaving the grid empty. diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 8c2940f7b..7bad77bc6 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -14,7 +14,9 @@ import TableProPluginKit // MARK: - Session Management extension DatabaseManager { - func connectToSession(_ connection: DatabaseConnection) async throws { + func connectToSession(_ requestedConnection: DatabaseConnection) async throws { + let connection = resolvedConnectionDefinition(for: requestedConnection) + if let existing = activeSessions[connection.id], existing.driver != nil { switchToSession(connection.id) return @@ -178,6 +180,10 @@ extension DatabaseManager { } } + internal func resolvedConnectionDefinition(for connection: DatabaseConnection) -> DatabaseConnection { + connectionStorage.loadConnection(id: connection.id) ?? connection + } + internal func finalizeConnectionFailure(for connectionId: UUID, cancelled: Bool) { guard !cancelled else { return } removeSessionEntry(for: connectionId) @@ -388,9 +394,12 @@ extension DatabaseManager { } func setSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) { - guard var session = activeSessions[connectionId], session.safeModeLevel != level else { return } + guard var session = activeSessions[connectionId] else { return } + guard session.safeModeLevel != level || session.connection.safeModeLevel != level else { return } session.safeModeLevel = level + session.connection.safeModeLevel = level setSession(session, for: connectionId) + _ = connectionStorage.updateSafeModeLevel(level, for: connectionId) } internal func setSession(_ session: ConnectionSession, for connectionId: UUID) { diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 903124914..65a33a89f 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -107,6 +107,10 @@ final class ConnectionStorage { } } + func loadConnection(id: UUID) -> DatabaseConnection? { + loadConnections().first { $0.id == id } + } + /// Save all connections. Returns `true` if persisted, `false` if encoding or /// the atomic write failed. Callers that mutate dependent state (sync tracker, /// keychain entries) MUST check the return value and abort on `false`. @@ -195,6 +199,34 @@ final class ConnectionStorage { return true } + @discardableResult + func updateSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) -> Bool { + var connections = loadConnections() + guard let index = connections.firstIndex(where: { $0.id == connectionId }) else { + Self.logger.notice( + "Skipped updateSafeModeLevel: connection not found for \(connectionId, privacy: .public)" + ) + return false + } + + guard connections[index].safeModeLevel != level else { return true } + + connections[index].safeModeLevel = level + guard saveConnections(connections) else { + Self.logger.error( + "Aborted updateSafeModeLevel: persistence failed for \(connectionId, privacy: .public)" + ) + return false + } + + let updatedConnection = connections[index] + if !updatedConnection.localOnly && !updatedConnection.isSample { + syncTracker.markDirty(.connection, id: updatedConnection.id.uuidString) + } + + return true + } + /// Delete a connection func deleteConnection(_ connection: DatabaseConnection) { var connections = loadConnections() diff --git a/TableProTests/Core/Storage/SafeModeMigrationTests.swift b/TableProTests/Core/Storage/SafeModeMigrationTests.swift index 450d52e8d..c91b36083 100644 --- a/TableProTests/Core/Storage/SafeModeMigrationTests.swift +++ b/TableProTests/Core/Storage/SafeModeMigrationTests.swift @@ -6,15 +6,16 @@ // import Foundation +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro @Suite("SafeModeMigration") @MainActor struct SafeModeMigrationTests { private let storage: ConnectionStorage private let defaults: UserDefaults + private let tracker: SyncChangeTracker init() { let unique = UUID().uuidString @@ -26,8 +27,15 @@ struct SafeModeMigrationTests { withIntermediateDirectories: true ) let suiteName = "com.TablePro.tests.ConnectionStorage.\(unique)" - self.defaults = UserDefaults(suiteName: suiteName)! - self.storage = ConnectionStorage(fileURL: fileURL, userDefaults: defaults) + guard let defaults = UserDefaults(suiteName: suiteName), + let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.Sync.\(unique)") + else { + fatalError("Failed to create isolated test user defaults") + } + self.defaults = defaults + let metadata = SyncMetadataStorage(userDefaults: syncDefaults) + self.tracker = SyncChangeTracker(metadataStorage: metadata) + self.storage = ConnectionStorage(fileURL: fileURL, userDefaults: defaults, syncTracker: tracker) } // MARK: - Round-Trip Through ConnectionStorage API @@ -36,7 +44,7 @@ struct SafeModeMigrationTests { func roundTripSilent() throws { let id = UUID() let connection = DatabaseConnection( - id: id, name: "Silent Test", host: "127.0.0.1", port: 3306, + id: id, name: "Silent Test", host: "127.0.0.1", port: 3_306, database: "test", username: "root", type: .mysql, safeModeLevel: .silent ) @@ -51,7 +59,7 @@ struct SafeModeMigrationTests { func roundTripAlert() throws { let id = UUID() let connection = DatabaseConnection( - id: id, name: "Alert Test", host: "127.0.0.1", port: 5432, + id: id, name: "Alert Test", host: "127.0.0.1", port: 5_432, database: "test", username: "postgres", type: .postgresql, safeModeLevel: .alert ) @@ -66,7 +74,7 @@ struct SafeModeMigrationTests { func roundTripAlertFull() throws { let id = UUID() let connection = DatabaseConnection( - id: id, name: "AlertFull Test", host: "127.0.0.1", port: 3306, + id: id, name: "AlertFull Test", host: "127.0.0.1", port: 3_306, database: "test", username: "root", type: .mysql, safeModeLevel: .alertFull ) @@ -81,7 +89,7 @@ struct SafeModeMigrationTests { func roundTripSafeMode() throws { let id = UUID() let connection = DatabaseConnection( - id: id, name: "SafeMode Test", host: "127.0.0.1", port: 3306, + id: id, name: "SafeMode Test", host: "127.0.0.1", port: 3_306, database: "test", username: "root", type: .mysql, safeModeLevel: .safeMode ) @@ -96,7 +104,7 @@ struct SafeModeMigrationTests { func roundTripSafeModeFull() throws { let id = UUID() let connection = DatabaseConnection( - id: id, name: "SafeModeFull Test", host: "127.0.0.1", port: 3306, + id: id, name: "SafeModeFull Test", host: "127.0.0.1", port: 3_306, database: "test", username: "root", type: .mysql, safeModeLevel: .safeModeFull ) @@ -111,7 +119,7 @@ struct SafeModeMigrationTests { func roundTripReadOnly() throws { let id = UUID() let connection = DatabaseConnection( - id: id, name: "ReadOnly Test", host: "127.0.0.1", port: 3306, + id: id, name: "ReadOnly Test", host: "127.0.0.1", port: 3_306, database: "test", username: "root", type: .mysql, safeModeLevel: .readOnly ) @@ -122,6 +130,144 @@ struct SafeModeMigrationTests { #expect(found?.safeModeLevel == .readOnly) } + @Test("setSafeModeLevel updates the active session and saved connection default") + func setSafeModeLevelPersistsUpdatedDefault() { + let id = UUID() + let connection = DatabaseConnection( + id: id, + name: "Persisted Safe Mode", + host: "127.0.0.1", + port: 3_306, + database: "test", + username: "root", + type: .mysql, + safeModeLevel: .silent + ) + + storage.addConnection(connection) + tracker.clearDirty(.connection, id: id.uuidString) + + let manager = DatabaseManager(connectionStorage: storage) + manager.injectSession(ConnectionSession(connection: connection), for: id) + defer { manager.removeSession(for: id) } + + manager.setSafeModeLevel(.readOnly, for: id) + + let session = manager.session(for: id) + let saved = storage.loadConnections().first { $0.id == id } + + #expect(session?.safeModeLevel == .readOnly) + #expect(session?.connection.safeModeLevel == .readOnly) + #expect(saved?.safeModeLevel == .readOnly) + #expect(tracker.dirtyRecords(for: .connection).contains(id.uuidString)) + } + + @Test("resolvedConnectionDefinition prefers the persisted safe mode over a stale caller copy") + func resolvedConnectionDefinitionUsesPersistedSafeMode() { + let id = UUID() + let staleConnection = DatabaseConnection( + id: id, + name: "Stale Safe Mode", + host: "127.0.0.1", + port: 3_306, + database: "test", + username: "root", + type: .mysql, + safeModeLevel: .silent + ) + + storage.addConnection(staleConnection) + + let manager = DatabaseManager(connectionStorage: storage) + manager.injectSession(ConnectionSession(connection: staleConnection), for: id) + manager.setSafeModeLevel(.alertFull, for: id) + manager.removeSession(for: id) + + let resolved = manager.resolvedConnectionDefinition(for: staleConnection) + + #expect(staleConnection.safeModeLevel == .silent) + #expect(resolved.safeModeLevel == .alertFull) + } + + @Test("A fresh session seeds from the persisted safe mode after disconnect") + func freshSessionSeedsFromPersistedSafeMode() { + let id = UUID() + let connection = DatabaseConnection( + id: id, + name: "Reconnect Safe Mode", + host: "127.0.0.1", + port: 5_432, + database: "test", + username: "postgres", + type: .postgresql, + safeModeLevel: .silent + ) + + storage.addConnection(connection) + + let manager = DatabaseManager(connectionStorage: storage) + manager.injectSession(ConnectionSession(connection: connection), for: id) + manager.setSafeModeLevel(.alertFull, for: id) + manager.removeSession(for: id) + + let reloaded = storage.loadConnections().first { $0.id == id } + let reseededSession = reloaded.map { ConnectionSession(connection: $0) } + + #expect(reloaded?.safeModeLevel == .alertFull) + #expect(reseededSession?.safeModeLevel == .alertFull) + } + + @Test("updateSafeModeLevel preserves the saved password and marks sync dirty") + func updateSafeModeLevelPreservesPasswordAndMarksDirty() { + let id = UUID() + let connection = DatabaseConnection( + id: id, + name: "Password Preservation", + host: "127.0.0.1", + port: 3_306, + database: "test", + username: "root", + type: .mysql, + safeModeLevel: .silent + ) + + storage.addConnection(connection, password: "secret") + tracker.clearDirty(.connection, id: id.uuidString) + defer { storage.deletePassword(for: id) } + + let updated = storage.updateSafeModeLevel(.safeModeFull, for: id) + + #expect(updated) + #expect(storage.loadPassword(for: id) == "secret") + #expect(storage.loadConnection(id: id)?.safeModeLevel == .safeModeFull) + #expect(tracker.dirtyRecords(for: .connection).contains(id.uuidString)) + } + + @Test("updateSafeModeLevel skips sync dirtiness for local-only connections") + func updateSafeModeLevelSkipsSyncForLocalOnlyConnections() { + let id = UUID() + let connection = DatabaseConnection( + id: id, + name: "Local Safe Mode", + host: "127.0.0.1", + port: 3_306, + database: "test", + username: "root", + type: .mysql, + safeModeLevel: .silent, + localOnly: true + ) + + storage.addConnection(connection) + tracker.clearDirty(.connection, id: id.uuidString) + + let updated = storage.updateSafeModeLevel(.readOnly, for: id) + + #expect(updated) + #expect(storage.loadConnection(id: id)?.safeModeLevel == .readOnly) + #expect(!tracker.dirtyRecords(for: .connection).contains(id.uuidString)) + } + // MARK: - Default Level @Test("New connection defaults to silent safe mode level") From bcb799eb40fda455ce062bf47ef038bd96eded5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 27 May 2026 22:14:11 +0700 Subject: [PATCH 5/6] fix(connections): implement safe mode persistence and add write-through test --- CHANGELOG.md | 2 +- .../ConnectionStoragePersistenceTests.swift | 34 ++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3166125be..ebe27a049 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Changing Safe Mode from the toolbar now saves that level as the connection default, so disconnecting and reconnecting keeps the same protection. +- Safe mode level changes in the toolbar now persist as the connection default across reconnects. - Toolbar customizations now persist after closing and reopening a session window. (#1455) - Pasting rows with commas in a cell now keeps each value in its own column and preserves NULL vs the literal text "NULL". - BigQuery: switching to another table loads its data immediately instead of leaving the grid empty. diff --git a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift index 22f13b0aa..1ef6f6678 100644 --- a/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift +++ b/TableProTests/Core/Storage/ConnectionStoragePersistenceTests.swift @@ -4,9 +4,9 @@ // import Foundation +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro @Suite("ConnectionStorage Persistence") @MainActor @@ -26,8 +26,12 @@ struct ConnectionStoragePersistenceTests { withIntermediateDirectories: true ) let suiteName = "com.TablePro.tests.ConnectionStorage.\(unique)" - self.defaults = UserDefaults(suiteName: suiteName)! - let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.Sync.\(unique)")! + guard let defaults = UserDefaults(suiteName: suiteName), + let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.Sync.\(unique)") + else { + fatalError("Failed to create isolated test user defaults") + } + self.defaults = defaults let metadata = SyncMetadataStorage(userDefaults: syncDefaults) self.syncTracker = SyncChangeTracker(metadataStorage: metadata) self.storage = ConnectionStorage( @@ -49,12 +53,34 @@ struct ConnectionStoragePersistenceTests { #expect(reloaded.contains { $0.id == connection.id }) } + @Test("updateSafeModeLevel writes the new level through to disk") + func updateSafeModeLevelWritesThrough() { + let connection = DatabaseConnection( + name: "Write Through", + host: "127.0.0.1", + port: 3_306, + type: .mysql, + safeModeLevel: .silent + ) + + storage.addConnection(connection) + storage.invalidateCache() + #expect(storage.loadConnections().first { $0.id == connection.id }?.safeModeLevel == .silent) + + let updated = storage.updateSafeModeLevel(.readOnly, for: connection.id) + #expect(updated) + + storage.invalidateCache() + let reloaded = storage.loadConnections().first { $0.id == connection.id } + #expect(reloaded?.safeModeLevel == .readOnly) + } + @Test("round-trip save and load preserves connections") func roundTripSaveLoad() { let connection = DatabaseConnection( name: "Round Trip Test", host: "127.0.0.1", - port: 5432, + port: 5_432, type: .postgresql ) From 173feb37e1c98d763df1d73afe836b23cba6c7d3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 1 Jun 2026 01:26:07 +0700 Subject: [PATCH 6/6] refactor(connections): narrow safe-mode resolution to preserve in-session connection edits --- CLAUDE.md | 2 +- .../Database/DatabaseManager+Sessions.swift | 5 +++- .../Core/Storage/SafeModeMigrationTests.swift | 30 +++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f7c9d6caa..5935dc572 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,7 +168,7 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. | Tab state | JSON persistence | `TabPersistenceService` / `TabStateStorage` | | Filter presets | UserDefaults | `FilterSettingsStorage` | | Per-table filters | UserDefaults | `FilterSettingsStorage` (saves `appliedFilters` only) | -| Favorite tables | UserDefaults | `FavoriteTablesStorage` (global, by table name) | +| Favorite tables | UserDefaults | `FavoriteTablesStorage` (per connection + database + schema; iCloud-synced) | ### Logging & Debugging diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 7bad77bc6..1e2c2b923 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -181,7 +181,10 @@ extension DatabaseManager { } internal func resolvedConnectionDefinition(for connection: DatabaseConnection) -> DatabaseConnection { - connectionStorage.loadConnection(id: connection.id) ?? connection + guard let stored = connectionStorage.loadConnection(id: connection.id) else { return connection } + var resolved = connection + resolved.safeModeLevel = stored.safeModeLevel + return resolved } internal func finalizeConnectionFailure(for connectionId: UUID, cancelled: Bool) { diff --git a/TableProTests/Core/Storage/SafeModeMigrationTests.swift b/TableProTests/Core/Storage/SafeModeMigrationTests.swift index c91b36083..efabb398f 100644 --- a/TableProTests/Core/Storage/SafeModeMigrationTests.swift +++ b/TableProTests/Core/Storage/SafeModeMigrationTests.swift @@ -189,6 +189,36 @@ struct SafeModeMigrationTests { #expect(resolved.safeModeLevel == .alertFull) } + @Test("resolvedConnectionDefinition keeps in-session connection edits and only refreshes safe mode") + func resolvedConnectionDefinitionPreservesInSessionEdits() { + let id = UUID() + let stored = DatabaseConnection( + id: id, + name: "Switched Database", + host: "127.0.0.1", + port: 5_432, + database: "original", + username: "postgres", + type: .postgresql, + safeModeLevel: .silent + ) + + storage.addConnection(stored) + + let manager = DatabaseManager(connectionStorage: storage) + manager.injectSession(ConnectionSession(connection: stored), for: id) + manager.setSafeModeLevel(.alertFull, for: id) + manager.removeSession(for: id) + + var inSession = stored + inSession.database = "switched" + + let resolved = manager.resolvedConnectionDefinition(for: inSession) + + #expect(resolved.database == "switched") + #expect(resolved.safeModeLevel == .alertFull) + } + @Test("A fresh session seeds from the persisted safe mode after disconnect") func freshSessionSeedsFromPersistedSafeMode() { let id = UUID()