diff --git a/CHANGELOG.md b/CHANGELOG.md index 765c70c04..f0c98299f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Confirmation dialogs for deep link queries, connection imports, and pre-connect scripts - JSON fields in Row Details sidebar now display in a scrollable monospaced text area +### Changed + +- Removed query history sync from iCloud Sync (connections, groups, settings, and SSH profiles still sync) + ### Fixed - SQL editor not auto-focused on new tab and cursor missing after tab switch diff --git a/TablePro/Core/Storage/QueryHistoryStorage.swift b/TablePro/Core/Storage/QueryHistoryStorage.swift index 3a40a5f40..139b4260a 100644 --- a/TablePro/Core/Storage/QueryHistoryStorage.swift +++ b/TablePro/Core/Storage/QueryHistoryStorage.swift @@ -164,8 +164,7 @@ final class QueryHistoryStorage { execution_time REAL NOT NULL, row_count INTEGER NOT NULL, was_successful INTEGER NOT NULL, - error_message TEXT, - is_synced INTEGER DEFAULT 0 + error_message TEXT ); """ @@ -206,7 +205,6 @@ final class QueryHistoryStorage { // Execute all table creation statements execute(historyTable) - migrateAddIsSyncedColumn() execute(ftsTable) execute(ftsInsertTrigger) execute(ftsDeleteTrigger) @@ -550,80 +548,6 @@ final class QueryHistoryStorage { } } - // MARK: - Sync Support - - /// Migration: add is_synced column if the table was created before sync support - private func migrateAddIsSyncedColumn() { - // Check if column already exists by querying table info - let sql = "PRAGMA table_info(history);" - var statement: OpaquePointer? - guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK else { return } - defer { sqlite3_finalize(statement) } - - var hasIsSynced = false - while sqlite3_step(statement) == SQLITE_ROW { - if let name = sqlite3_column_text(statement, 1).map({ String(cString: $0) }), - name == "is_synced" { - hasIsSynced = true - break - } - } - - if !hasIsSynced { - execute("ALTER TABLE history ADD COLUMN is_synced INTEGER DEFAULT 0;") - Self.logger.info("Migrated history table: added is_synced column") - } - } - - /// Mark history entries as synced - func markHistoryEntriesSynced(ids: [String]) async { - guard !ids.isEmpty else { return } - await performDatabaseWork { [weak self] in - guard let self else { return } - - let placeholders = ids.map { _ in "?" }.joined(separator: ", ") - let sql = "UPDATE history SET is_synced = 1 WHERE id IN (\(placeholders));" - - var statement: OpaquePointer? - guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { return } - defer { sqlite3_finalize(statement) } - - let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self) - for (index, id) in ids.enumerated() { - sqlite3_bind_text(statement, Int32(index + 1), id, -1, SQLITE_TRANSIENT) - } - sqlite3_step(statement) - } - } - - /// Fetch unsynced history entries - func unsyncedHistoryEntries(limit: Int) async -> [QueryHistoryEntry] { - await performDatabaseWork { [weak self] in - guard let self else { return [] } - - let sql = """ - SELECT id, query, connection_id, database_name, executed_at, execution_time, row_count, was_successful, error_message - FROM history WHERE is_synced = 0 ORDER BY executed_at DESC LIMIT ?; - """ - - var statement: OpaquePointer? - guard sqlite3_prepare_v2(self.db, sql, -1, &statement, nil) == SQLITE_OK else { - return [] - } - defer { sqlite3_finalize(statement) } - - sqlite3_bind_int(statement, 1, Int32(limit)) - - var entries: [QueryHistoryEntry] = [] - while sqlite3_step(statement) == SQLITE_ROW { - if let entry = self.parseHistoryEntry(from: statement) { - entries.append(entry) - } - } - return entries - } - } - // MARK: - Parsing Helpers private func parseHistoryEntry(from statement: OpaquePointer?) -> QueryHistoryEntry? { diff --git a/TablePro/Core/Sync/SyncCoordinator.swift b/TablePro/Core/Sync/SyncCoordinator.swift index caaf2d2da..fef79093c 100644 --- a/TablePro/Core/Sync/SyncCoordinator.swift +++ b/TablePro/Core/Sync/SyncCoordinator.swift @@ -267,28 +267,6 @@ final class SyncCoordinator { collectDirtySSHProfiles(into: &recordsToSave, deletions: &recordIDsToDelete, zoneID: zoneID) } - // Collect unsynced query history - if settings.syncQueryHistory { - let limit = settings.historySyncLimit.limit ?? Int.max - let unsyncedEntries = await QueryHistoryStorage.shared.unsyncedHistoryEntries(limit: limit) - for entry in unsyncedEntries { - recordsToSave.append( - SyncRecordMapper.toCKRecord( - entryId: entry.id.uuidString, - query: entry.query, - connectionId: entry.connectionId.uuidString, - databaseName: entry.databaseName, - executedAt: entry.executedAt, - executionTime: entry.executionTime, - rowCount: Int64(entry.rowCount), - wasSuccessful: entry.wasSuccessful, - errorMessage: entry.errorMessage, - in: zoneID - ) - ) - } - } - // Collect dirty settings if settings.syncSettings { let dirtySettingsIds = changeTracker.dirtyRecords(for: .settings) @@ -320,9 +298,6 @@ final class SyncCoordinator { if settings.syncSettings { changeTracker.clearAllDirty(.settings) } - if settings.syncQueryHistory { - changeTracker.clearAllDirty(.queryHistory) - } // Clear tombstones only for types that were actually pushed if settings.syncConnections { @@ -348,19 +323,6 @@ final class SyncCoordinator { metadataStorage.removeTombstone(type: .settings, id: tombstone.id) } } - if settings.syncQueryHistory { - for tombstone in metadataStorage.tombstones(for: .queryHistory) { - metadataStorage.removeTombstone(type: .queryHistory, id: tombstone.id) - } - - // Mark pushed history entries as synced in local storage - let syncedIds = recordsToSave - .filter { $0.recordType == SyncRecordType.queryHistory.rawValue } - .compactMap { $0["entryId"] as? String } - if !syncedIds.isEmpty { - await QueryHistoryStorage.shared.markHistoryEntriesSynced(ids: syncedIds) - } - } Self.logger.info("Push completed: \(recordsToSave.count) saved, \(recordIDsToDelete.count) deleted") } catch let error as CKError where error.code == .serverRecordChanged { @@ -441,8 +403,6 @@ final class SyncCoordinator { applyRemoteSSHProfile(record) case SyncRecordType.settings.rawValue where settings.syncSettings: applyRemoteSettings(record) - case SyncRecordType.queryHistory.rawValue where settings.syncQueryHistory: - applyRemoteQueryHistory(record) default: break } @@ -536,38 +496,6 @@ final class SyncCoordinator { applySettingsData(data, for: category) } - private func applyRemoteQueryHistory(_ record: CKRecord) { - guard let entryIdString = record["entryId"] as? String, - let entryId = UUID(uuidString: entryIdString), - let query = record["query"] as? String, - let executedAt = record["executedAt"] as? Date - else { return } - - let connectionId = (record["connectionId"] as? String).flatMap { UUID(uuidString: $0) } ?? UUID() - let databaseName = record["databaseName"] as? String ?? "" - let executionTime = record["executionTime"] as? Double ?? 0 - let rowCount = (record["rowCount"] as? Int64).map { Int($0) } ?? 0 - let wasSuccessful = (record["wasSuccessful"] as? Int64 ?? 1) != 0 - let errorMessage = record["errorMessage"] as? String - - let entry = QueryHistoryEntry( - id: entryId, - query: query, - connectionId: connectionId, - databaseName: databaseName, - executedAt: executedAt, - executionTime: executionTime, - rowCount: rowCount, - wasSuccessful: wasSuccessful, - errorMessage: errorMessage - ) - - Task { - _ = await QueryHistoryStorage.shared.addHistory(entry) - await QueryHistoryStorage.shared.markHistoryEntriesSynced(ids: [entryIdString]) - } - } - private func applyRemoteDeletion(_ recordID: CKRecord.ID) { let recordName = recordID.recordName @@ -714,7 +642,6 @@ final class SyncCoordinator { case SyncRecordType.group.rawValue: syncRecordType = .group case SyncRecordType.tag.rawValue: syncRecordType = .tag case SyncRecordType.settings.rawValue: syncRecordType = .settings - case SyncRecordType.queryHistory.rawValue: syncRecordType = .queryHistory case SyncRecordType.sshProfile.rawValue: syncRecordType = .sshProfile default: continue } diff --git a/TablePro/Core/Sync/SyncRecordMapper.swift b/TablePro/Core/Sync/SyncRecordMapper.swift index 5a6c3c1e9..c5e17eeb6 100644 --- a/TablePro/Core/Sync/SyncRecordMapper.swift +++ b/TablePro/Core/Sync/SyncRecordMapper.swift @@ -15,7 +15,6 @@ enum SyncRecordType: String, CaseIterable { case group = "ConnectionGroup" case tag = "ConnectionTag" case settings = "AppSettings" - case queryHistory = "QueryHistory" case favorite = "SQLFavorite" case favoriteFolder = "SQLFavoriteFolder" case sshProfile = "SSHProfile" @@ -30,9 +29,6 @@ struct SyncRecordMapper { /// Current schema version stamped on every record static let schemaVersion: Int64 = 1 - /// Maximum query text length for CloudKit (10KB in UTF-8) - static let maxQueryLength = 10_240 - // MARK: - Record Name Helpers static func recordID(type: SyncRecordType, id: String, in zone: CKRecordZone.ID) -> CKRecord.ID { @@ -42,7 +38,6 @@ struct SyncRecordMapper { case .group: recordName = "Group_\(id)" case .tag: recordName = "Tag_\(id)" case .settings: recordName = "Settings_\(id)" - case .queryHistory: recordName = "History_\(id)" case .favorite: recordName = "Favorite_\(id)" case .favoriteFolder: recordName = "FavoriteFolder_\(id)" case .sshProfile: recordName = "SSHProfile_\(id)" @@ -275,58 +270,6 @@ struct SyncRecordMapper { record["settingsJson"] as? Data } - // MARK: - Query History - - static func toCKRecord( - entryId: String, - query: String, - connectionId: String?, - databaseName: String?, - executedAt: Date, - executionTime: Double, - rowCount: Int64, - wasSuccessful: Bool, - errorMessage: String?, - in zone: CKRecordZone.ID - ) -> CKRecord { - let recordID = recordID(type: .queryHistory, id: entryId, in: zone) - let record = CKRecord( - recordType: SyncRecordType.queryHistory.rawValue, - recordID: recordID - ) - - record["entryId"] = entryId as CKRecordValue - // Cap query text at maxQueryLength bytes - let cappedQuery: String - let queryData = Data(query.utf8) - if queryData.count > maxQueryLength { - cappedQuery = String( - data: queryData.prefix(maxQueryLength), - encoding: .utf8 - ) ?? String(query.prefix(maxQueryLength / 4)) - } else { - cappedQuery = query - } - record["query"] = cappedQuery as CKRecordValue - record["executedAt"] = executedAt as CKRecordValue - record["executionTime"] = executionTime as CKRecordValue - record["rowCount"] = rowCount as CKRecordValue - record["wasSuccessful"] = Int64(wasSuccessful ? 1 : 0) as CKRecordValue - record["schemaVersion"] = schemaVersion as CKRecordValue - - if let connectionId { - record["connectionId"] = connectionId as CKRecordValue - } - if let databaseName { - record["databaseName"] = databaseName as CKRecordValue - } - if let errorMessage { - record["errorMessage"] = errorMessage as CKRecordValue - } - - return record - } - // MARK: - SSH Profile static func toCKRecord(_ profile: SSHProfile, in zone: CKRecordZone.ID) -> CKRecord { diff --git a/TablePro/Models/Settings/SyncSettings.swift b/TablePro/Models/Settings/SyncSettings.swift index b1e742015..41e9228ca 100644 --- a/TablePro/Models/Settings/SyncSettings.swift +++ b/TablePro/Models/Settings/SyncSettings.swift @@ -13,8 +13,6 @@ struct SyncSettings: Codable, Equatable { var syncConnections: Bool var syncGroupsAndTags: Bool var syncSettings: Bool - var syncQueryHistory: Bool - var historySyncLimit: HistorySyncLimit var syncPasswords: Bool var syncSSHProfiles: Bool @@ -23,8 +21,6 @@ struct SyncSettings: Codable, Equatable { syncConnections: Bool, syncGroupsAndTags: Bool, syncSettings: Bool, - syncQueryHistory: Bool, - historySyncLimit: HistorySyncLimit, syncPasswords: Bool = false, syncSSHProfiles: Bool = true ) { @@ -32,8 +28,6 @@ struct SyncSettings: Codable, Equatable { self.syncConnections = syncConnections self.syncGroupsAndTags = syncGroupsAndTags self.syncSettings = syncSettings - self.syncQueryHistory = syncQueryHistory - self.historySyncLimit = historySyncLimit self.syncPasswords = syncPasswords self.syncSSHProfiles = syncSSHProfiles } @@ -44,8 +38,6 @@ struct SyncSettings: Codable, Equatable { syncConnections = try container.decode(Bool.self, forKey: .syncConnections) syncGroupsAndTags = try container.decode(Bool.self, forKey: .syncGroupsAndTags) syncSettings = try container.decode(Bool.self, forKey: .syncSettings) - syncQueryHistory = try container.decode(Bool.self, forKey: .syncQueryHistory) - historySyncLimit = try container.decode(HistorySyncLimit.self, forKey: .historySyncLimit) syncPasswords = try container.decodeIfPresent(Bool.self, forKey: .syncPasswords) ?? false syncSSHProfiles = try container.decodeIfPresent(Bool.self, forKey: .syncSSHProfiles) ?? true } @@ -55,35 +47,7 @@ struct SyncSettings: Codable, Equatable { syncConnections: true, syncGroupsAndTags: true, syncSettings: true, - syncQueryHistory: true, - historySyncLimit: .entries500, syncPasswords: false, syncSSHProfiles: true ) } - -/// Maximum number of query history entries to sync -enum HistorySyncLimit: String, Codable, CaseIterable { - case entries100 = "100" - case entries500 = "500" - case entries1000 = "1000" - case unlimited = "unlimited" - - var displayName: String { - switch self { - case .entries100: return "100" - case .entries500: return "500" - case .entries1000: return "1,000" - case .unlimited: return String(localized: "Unlimited") - } - } - - var limit: Int? { - switch self { - case .entries100: return 100 - case .entries500: return 500 - case .entries1000: return 1_000 - case .unlimited: return nil - } - } -} diff --git a/TablePro/Views/Components/ConflictResolutionView.swift b/TablePro/Views/Components/ConflictResolutionView.swift index 09704871e..1388267ed 100644 --- a/TablePro/Views/Components/ConflictResolutionView.swift +++ b/TablePro/Views/Components/ConflictResolutionView.swift @@ -130,14 +130,6 @@ struct ConflictResolutionView: View { if let color = record["color"] as? String { fieldRow(label: "Color", value: color) } - case .queryHistory: - if let query = record["query"] as? String { - let nsQuery = query as NSString - let preview = nsQuery.length > 80 - ? nsQuery.substring(to: 80) + "..." - : query - fieldRow(label: "Query", value: preview) - } case .favorite, .favoriteFolder: if let name = record["name"] as? String { fieldRow(label: String(localized: "Name"), value: name) diff --git a/TablePro/Views/Settings/SyncSettingsView.swift b/TablePro/Views/Settings/SyncSettingsView.swift index 6c27497fd..fe7af2ff3 100644 --- a/TablePro/Views/Settings/SyncSettingsView.swift +++ b/TablePro/Views/Settings/SyncSettingsView.swift @@ -27,7 +27,7 @@ struct SyncSettingsView: View { } } - Text("Syncs connections, settings, and history across your Macs via iCloud.") + Text("Syncs connections, settings, and SSH profiles across your Macs via iCloud.") .font(.caption) .foregroundStyle(.secondary) } @@ -138,18 +138,6 @@ struct SyncSettingsView: View { Toggle("Settings:", isOn: $syncSettings.syncSettings) .onChange(of: syncSettings.syncSettings) { _, _ in persistSettings() } - - Toggle("Query History:", isOn: $syncSettings.syncQueryHistory) - .onChange(of: syncSettings.syncQueryHistory) { _, _ in persistSettings() } - - if syncSettings.syncQueryHistory { - Picker("History Limit:", selection: $syncSettings.historySyncLimit) { - ForEach(HistorySyncLimit.allCases, id: \.self) { limit in - Text(limit.displayName).tag(limit) - } - } - .onChange(of: syncSettings.historySyncLimit) { _, _ in persistSettings() } - } } } diff --git a/docs/features/icloud-sync.mdx b/docs/features/icloud-sync.mdx index e71530c5a..20a835b81 100644 --- a/docs/features/icloud-sync.mdx +++ b/docs/features/icloud-sync.mdx @@ -1,11 +1,11 @@ --- title: iCloud Sync -description: Sync connections, settings, and query history across Macs via iCloud (Pro feature) +description: Sync connections, settings, and SSH profiles across Macs via iCloud (Pro feature) --- # iCloud Sync -TablePro syncs your connections, groups, settings, and query history across all your Macs via CloudKit. iCloud Sync is a Pro feature that requires an active license. +TablePro syncs your connections, groups, settings, and SSH profiles across all your Macs via CloudKit. iCloud Sync is a Pro feature that requires an active license. ## What syncs (and what doesn't) @@ -15,7 +15,6 @@ TablePro syncs your connections, groups, settings, and query history across all | **Passwords** | Optional | Opt-in via iCloud Keychain (end-to-end encrypted) | | **Groups & Tags** | Yes | Full connection organization, including nested group hierarchy (parent-child relationships and sort order) | | **App Settings** | Yes | All 8 categories (General, Appearance, Editor, Data Grid, History, Tabs, Keyboard, AI) | -| **Query History** | Yes | Configurable limit: 100, 500, 1,000, or unlimited | Passwords are not synced by default. Enable **Password sync** under the Connections toggle to sync passwords via Apple's iCloud Keychain (end-to-end encrypted). With password sync off, you need to enter the password once on each new Mac. @@ -39,7 +38,7 @@ Open **Settings** (`Cmd+,`) > **Sync**, toggle iCloud Sync on, choose which cate /> -Each data type has its own toggle: Connections, Groups & Tags, App Settings, and Query History (with a configurable limit: 100, 500, 1,000, or unlimited). Enable **Passwords** to sync credentials via iCloud Keychain (end-to-end encrypted). Off by default. +Each data type has its own toggle: Connections, Groups & Tags, SSH Profiles, and App Settings. Enable **Passwords** to sync credentials via iCloud Keychain (end-to-end encrypted). Off by default. TablePro auto-syncs on app launch, when you switch back to it, and 2 seconds after you modify synced data. Use the **Sync Now** button to trigger a manual sync.