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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Opening a saved connection that fails now shows the detailed troubleshooting dialog with suggested fixes, the same one Test Connection shows, instead of a generic error alert. (#1425, #483)
- Oracle connection errors no longer surface the driver's raw internal message; failures now explain the cause in plain language. (#483)
- AWS IAM authentication with a named profile now reads `~/.aws/config` (not just `~/.aws/credentials`) and supports `credential_process`, so profiles backed by SSO, IAM Identity Center, or assume-role work through `aws configure export-credentials`. (#1291)
- iOS: a connection's Safe Mode setting now survives relaunch. iCloud sync no longer drops the value, so a connection set to Confirm Writes or Read-Only no longer reverts to Off after reopening the app.

## [0.45.0] - 2026-05-26

Expand Down
5 changes: 5 additions & 0 deletions Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ let package = Package(
name: "TableProMSSQLCoreTests",
dependencies: ["TableProMSSQLCore"],
path: "Tests/TableProMSSQLCoreTests"
),
.testTarget(
name: "TableProSyncTests",
dependencies: ["TableProSync", "TableProModels"],
path: "Tests/TableProSyncTests"
)
]
)
14 changes: 14 additions & 0 deletions Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public enum SyncRecordMapper {
record["type"] = connection.type.rawValue as CKRecordValue
record["sortOrder"] = Int64(connection.sortOrder) as CKRecordValue
record["isReadOnly"] = Int64(connection.isReadOnly ? 1 : 0) as CKRecordValue
record["safeModeLevel"] = connection.safeModeLevel.rawValue as CKRecordValue
record["sshEnabled"] = Int64(connection.sshEnabled ? 1 : 0) as CKRecordValue
record["sslEnabled"] = Int64(connection.sslEnabled ? 1 : 0) as CKRecordValue

Expand Down Expand Up @@ -111,6 +112,7 @@ public enum SyncRecordMapper {
let tagId = (record["tagId"] as? String).flatMap { UUID(uuidString: $0) }
let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0
let isReadOnly = (record["isReadOnly"] as? Int64 ?? 0) != 0
let safeModeLevel = safeModeLevel(fromWire: record["safeModeLevel"] as? String, isReadOnly: isReadOnly)
let queryTimeout = (record["queryTimeoutSeconds"] as? Int64).map { Int($0) }
var sshConfig: SSHConfiguration?
if let sshData = record["sshConfigJson"] as? Data {
Expand Down Expand Up @@ -151,6 +153,7 @@ public enum SyncRecordMapper {
database: database,
colorTag: colorTag,
isReadOnly: isReadOnly,
safeModeLevel: safeModeLevel,
queryTimeoutSeconds: queryTimeout,
additionalFields: additionalFields,
sshEnabled: sshEnabled,
Expand All @@ -163,6 +166,16 @@ public enum SyncRecordMapper {
)
}

private static func safeModeLevel(fromWire raw: String?, isReadOnly: Bool) -> SafeModeLevel {
guard let raw else { return isReadOnly ? .readOnly : .off }
if let level = SafeModeLevel(rawValue: raw) { return level }
switch raw {
case "silent": return .off
case "alert", "alertFull", "safeMode", "safeModeFull": return .confirmWrites
default: return isReadOnly ? .readOnly : .off
}
}

// MARK: - Update Existing CKRecord (preserves macOS-only fields)

public static func updateRecord(_ record: CKRecord, with connection: DatabaseConnection) {
Expand All @@ -175,6 +188,7 @@ public enum SyncRecordMapper {
record["type"] = connection.type.rawValue as CKRecordValue
record["sortOrder"] = Int64(connection.sortOrder) as CKRecordValue
record["isReadOnly"] = Int64(connection.isReadOnly ? 1 : 0) as CKRecordValue
record["safeModeLevel"] = connection.safeModeLevel.rawValue as CKRecordValue
record["sshEnabled"] = Int64(connection.sshEnabled ? 1 : 0) as CKRecordValue
record["sslEnabled"] = Int64(connection.sslEnabled ? 1 : 0) as CKRecordValue

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import CloudKit
import Foundation
import Testing

@testable import TableProModels
@testable import TableProSync

@Suite("SyncRecordMapper safe mode")
struct SyncRecordMapperTests {
private let zoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName)

private func makeConnection(safeModeLevel: SafeModeLevel, isReadOnly: Bool = false) -> DatabaseConnection {
DatabaseConnection(
name: "Test",
type: .postgresql,
host: "db.example.com",
port: 5432,
username: "admin",
database: "app",
isReadOnly: isReadOnly,
safeModeLevel: safeModeLevel
)
}

private func makeRawRecord(safeModeLevelRaw: String?, isReadOnly: Bool? = nil) -> CKRecord {
let id = SyncRecordMapper.recordID(type: .connection, id: UUID().uuidString, in: zoneID)
let record = CKRecord(recordType: SyncRecordType.connection.rawValue, recordID: id)
record["connectionId"] = UUID().uuidString as CKRecordValue
record["name"] = "Test" as CKRecordValue
record["type"] = DatabaseType.postgresql.rawValue as CKRecordValue
if let safeModeLevelRaw {
record["safeModeLevel"] = safeModeLevelRaw as CKRecordValue
}
if let isReadOnly {
record["isReadOnly"] = Int64(isReadOnly ? 1 : 0) as CKRecordValue
}
return record
}

@Test("toRecord then toConnection preserves every safe mode level", arguments: SafeModeLevel.allCases)
func roundTripsEachLevel(_ level: SafeModeLevel) throws {
let record = SyncRecordMapper.toRecord(makeConnection(safeModeLevel: level), zoneID: zoneID)
let decoded = try #require(SyncRecordMapper.toConnection(record))
#expect(decoded.safeModeLevel == level)
}

@Test("updateRecord carries the new safe mode level")
func updateRecordPreservesLevel() throws {
let record = SyncRecordMapper.toRecord(makeConnection(safeModeLevel: .off), zoneID: zoneID)
SyncRecordMapper.updateRecord(record, with: makeConnection(safeModeLevel: .confirmWrites))
let decoded = try #require(SyncRecordMapper.toConnection(record))
#expect(decoded.safeModeLevel == .confirmWrites)
}

@Test("legacy record without safeModeLevel falls back to isReadOnly")
func legacyFallback() throws {
let readOnlyRecord = makeRawRecord(safeModeLevelRaw: nil, isReadOnly: true)
let readOnly = try #require(SyncRecordMapper.toConnection(readOnlyRecord))
#expect(readOnly.safeModeLevel == .readOnly)

let writableRecord = makeRawRecord(safeModeLevelRaw: nil, isReadOnly: false)
let writable = try #require(SyncRecordMapper.toConnection(writableRecord))
#expect(writable.safeModeLevel == .off)
}

@Test(
"macOS wire values map to the nearest iOS level",
arguments: [
("silent", SafeModeLevel.off),
("alert", SafeModeLevel.confirmWrites),
("alertFull", SafeModeLevel.confirmWrites),
("safeMode", SafeModeLevel.confirmWrites),
("safeModeFull", SafeModeLevel.confirmWrites),
("readOnly", SafeModeLevel.readOnly)
]
)
func decodesMacOSWireValues(_ raw: String, _ expected: SafeModeLevel) throws {
let decoded = try #require(SyncRecordMapper.toConnection(makeRawRecord(safeModeLevelRaw: raw)))
#expect(decoded.safeModeLevel == expected)
}
}
Loading