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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 1 addition & 77 deletions TablePro/Core/Storage/QueryHistoryStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
"""

Expand Down Expand Up @@ -206,7 +205,6 @@ final class QueryHistoryStorage {

// Execute all table creation statements
execute(historyTable)
migrateAddIsSyncedColumn()
execute(ftsTable)
execute(ftsInsertTrigger)
execute(ftsDeleteTrigger)
Expand Down Expand Up @@ -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? {
Expand Down
73 changes: 0 additions & 73 deletions TablePro/Core/Sync/SyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
}
Expand Down
57 changes: 0 additions & 57 deletions TablePro/Core/Sync/SyncRecordMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -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)"
Expand Down Expand Up @@ -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 {
Expand Down
36 changes: 0 additions & 36 deletions TablePro/Models/Settings/SyncSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -23,17 +21,13 @@ struct SyncSettings: Codable, Equatable {
syncConnections: Bool,
syncGroupsAndTags: Bool,
syncSettings: Bool,
syncQueryHistory: Bool,
historySyncLimit: HistorySyncLimit,
syncPasswords: Bool = false,
syncSSHProfiles: Bool = true
) {
self.enabled = enabled
self.syncConnections = syncConnections
self.syncGroupsAndTags = syncGroupsAndTags
self.syncSettings = syncSettings
self.syncQueryHistory = syncQueryHistory
self.historySyncLimit = historySyncLimit
self.syncPasswords = syncPasswords
self.syncSSHProfiles = syncSSHProfiles
}
Expand All @@ -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
}
Expand All @@ -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
}
}
}
8 changes: 0 additions & 8 deletions TablePro/Views/Components/ConflictResolutionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading