From d76f19e165a07dc1d155b691213278739447fd43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 09:17:35 +0700 Subject: [PATCH 1/2] fix(sync): propagate connection group membership changes to other devices --- CHANGELOG.md | 4 ++ TablePro/Core/Storage/GroupStorage.swift | 8 ++-- TablePro/ViewModels/WelcomeViewModel.swift | 8 +++- .../Core/Storage/GroupStorageTests.swift | 43 ++++++++++++++++++- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d643cac0e..9e81d4d0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Moving a connection into or out of a group now syncs across devices, instead of leaving it ungrouped on your other Macs. + ## [0.46.0] - 2026-05-28 ### Added diff --git a/TablePro/Core/Storage/GroupStorage.swift b/TablePro/Core/Storage/GroupStorage.swift index a67d4b293..9e04945c4 100644 --- a/TablePro/Core/Storage/GroupStorage.swift +++ b/TablePro/Core/Storage/GroupStorage.swift @@ -104,15 +104,15 @@ final class GroupStorage { let storage = connectionStorageProvider() var connections = storage.loadConnections() - var changed = false + var changed: [DatabaseConnection] = [] for i in connections.indices { if let gid = connections[i].groupId, allIdsToDelete.contains(gid) { connections[i].groupId = nil - changed = true + changed.append(connections[i]) } } - if changed { - if !storage.saveConnections(connections) { + if !changed.isEmpty { + if !storage.updateConnections(changed) { Self.logger.error("Failed to clear groupId references after group deletion") } } diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index 73bb2df10..2accc4738 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -415,10 +415,12 @@ final class WelcomeViewModel { func moveConnections(_ targets: [DatabaseConnection], toGroup groupId: UUID) { let ids = Set(targets.map(\.id)) + var updated: [DatabaseConnection] = [] for i in connections.indices where ids.contains(connections[i].id) { connections[i].groupId = groupId + updated.append(connections[i]) } - guard storage.saveConnections(connections) else { + guard storage.updateConnections(updated) else { connections = storage.loadConnections() rebuildTree() return @@ -428,10 +430,12 @@ final class WelcomeViewModel { func removeFromGroup(_ targets: [DatabaseConnection]) { let ids = Set(targets.map(\.id)) + var updated: [DatabaseConnection] = [] for i in connections.indices where ids.contains(connections[i].id) { connections[i].groupId = nil + updated.append(connections[i]) } - guard storage.saveConnections(connections) else { + guard storage.updateConnections(updated) else { connections = storage.loadConnections() rebuildTree() return diff --git a/TableProTests/Core/Storage/GroupStorageTests.swift b/TableProTests/Core/Storage/GroupStorageTests.swift index 35d1d3be6..1581bf01c 100644 --- a/TableProTests/Core/Storage/GroupStorageTests.swift +++ b/TableProTests/Core/Storage/GroupStorageTests.swift @@ -14,6 +14,9 @@ final class GroupStorageTests: XCTestCase { private var syncDefaults: UserDefaults! private var syncSuiteName: String! private var storage: GroupStorage! + private var tracker: SyncChangeTracker! + private var connectionStorage: ConnectionStorage! + private var connectionFileURL: URL! override func setUp() { super.setUp() @@ -23,18 +26,38 @@ final class GroupStorageTests: XCTestCase { syncSuiteName = "com.TablePro.tests.Sync.\(unique)" syncDefaults = UserDefaults(suiteName: syncSuiteName)! let metadata = SyncMetadataStorage(userDefaults: syncDefaults) - let tracker = SyncChangeTracker(metadataStorage: metadata) - storage = GroupStorage(userDefaults: defaults, syncTracker: tracker) + tracker = SyncChangeTracker(metadataStorage: metadata) + connectionFileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("group-connections_\(unique).json") + try? FileManager.default.createDirectory( + at: connectionFileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + connectionStorage = ConnectionStorage( + fileURL: connectionFileURL, + userDefaults: defaults, + syncTracker: tracker + ) + storage = GroupStorage( + userDefaults: defaults, + syncTracker: tracker, + connectionStorage: connectionStorage + ) } override func tearDown() { defaults.removePersistentDomain(forName: suiteName) syncDefaults.removePersistentDomain(forName: syncSuiteName) + try? FileManager.default.removeItem(at: connectionFileURL) defaults = nil suiteName = nil syncDefaults = nil syncSuiteName = nil storage = nil + tracker = nil + connectionStorage = nil + connectionFileURL = nil super.tearDown() } @@ -129,6 +152,22 @@ final class GroupStorageTests: XCTestCase { XCTAssertEqual(loaded[0].name, "Prod") } + func testDeleteGroupClearsMembershipAndMarksConnectionDirtyForSync() { + let group = ConnectionGroup(name: "Dev", color: .green) + storage.saveGroups([group]) + + let connection = DatabaseConnection(name: "Grouped", groupId: group.id) + connectionStorage.addConnection(connection) + tracker.clearAllDirty(.connection) + + storage.deleteGroup(group) + + let reloaded = connectionStorage.loadConnections() + XCTAssertEqual(reloaded.count, 1) + XCTAssertNil(reloaded[0].groupId) + XCTAssertTrue(tracker.dirtyRecords(for: .connection).contains(connection.id.uuidString)) + } + // MARK: - Lookup func testGroupForId() { From caebe3d902ac107048614d0ba978fbedd6b198a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 29 May 2026 12:32:11 +0700 Subject: [PATCH 2/2] refactor(connections): route reorder through updateConnections and batch dirty marking --- TablePro/Core/Storage/ConnectionStorage.swift | 7 ++++--- TablePro/ViewModels/WelcomeViewModel.swift | 18 ++++++------------ 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 903124914..9ea56aac8 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -189,9 +189,10 @@ final class ConnectionStorage { guard didMutate, saveConnections(connections) else { return false } - for connection in updatesById.values where !connection.localOnly && !connection.isSample { - syncTracker.markDirty(.connection, id: connection.id.uuidString) - } + let dirtyIds = updatesById.values + .filter { !$0.localOnly && !$0.isSample } + .map { $0.id.uuidString } + syncTracker.markDirty(.connection, ids: dirtyIds) return true } diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index 2accc4738..e4ef44afc 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -553,26 +553,23 @@ final class WelcomeViewModel { let updatedValidGroupIds = Set(groups.map(\.id)) var order = 0 - var dirtyIds: [String] = [] + var updated: [DatabaseConnection] = [] for i in connections.indices { let isUngrouped = connections[i].groupId.map { !updatedValidGroupIds.contains($0) } ?? true if isUngrouped { if connections[i].sortOrder != order { connections[i].sortOrder = order - dirtyIds.append(connections[i].id.uuidString) + updated.append(connections[i]) } order += 1 } } - guard storage.saveConnections(connections) else { + guard storage.updateConnections(updated) else { connections = storage.loadConnections() rebuildTree() return } - if !dirtyIds.isEmpty { - services.syncTracker.markDirty(.connection, ids: dirtyIds) - } rebuildTree() } @@ -595,23 +592,20 @@ final class WelcomeViewModel { connections.move(fromOffsets: globalSource, toOffset: globalDestination) var order = 0 - var dirtyIds: [String] = [] + var updated: [DatabaseConnection] = [] for i in connections.indices where connections[i].groupId == group.id { if connections[i].sortOrder != order { connections[i].sortOrder = order - dirtyIds.append(connections[i].id.uuidString) + updated.append(connections[i]) } order += 1 } - guard storage.saveConnections(connections) else { + guard storage.updateConnections(updated) else { connections = storage.loadConnections() rebuildTree() return } - if !dirtyIds.isEmpty { - services.syncTracker.markDirty(.connection, ids: dirtyIds) - } rebuildTree() }