Commit 2ed92d773a

2ed92d773a2505ac3b6fbd3d280258549936ba2a

parent: 0aa8cdf3bb

Unsigned

cmc <hello@cleberg.net> · 2026-07-24 23:17 UTC

fix: sync CloudKit through a custom zone instead of queries (#29)

With the entitlement gate removed, enabling iCloud sync surfaced a chain
of CloudKit errors, each rooted in the query-based fetch:

- "Did not find record type: TrackedDomain" — a fresh container has no
  schema until the first save, so querying any type failed.
- "SharedDB does not support Zone Wide queries" — the shared database
  rejects database-wide queries.
- "field 'recordName' is not marked queryable" — TRUEPREDICATE queries
  require a Queryable index on recordName, which auto-created development
  schemas do not have.

All three are inherent to reading with CKQuery. Move the user's own
records into a single custom record zone and read every record with
CKFetchRecordZoneChangesOperation, which needs no queryable indexes and
works on a brand-new zone. The shared database is read the same way, one
zone per accepted share. A custom zone is also a prerequisite for CloudKit
sharing, so share root records now live there too.

- Ensure the custom zone exists before every push and fetch (idempotent).
- Save all records — and build tombstone delete IDs — in the custom zone.
- Replace the per-type CKQuery fetch with a per-zone change fetch that
  drains truncated responses via the server change token.

Layout: unified · split

DomainDig/CloudSyncService.swift +131 −109
@@ -57,6 +57,10 @@ enum ShareableEntity: Identifiable, Hashable {
5757 }
5858}
5959
60private enum CloudZone {
61 static let name = "DomainDigZone"
62}
63
6064private enum CloudRecordType {
6165 static let trackedDomain = "TrackedDomain"
6266 static let workflow = "DomainWorkflow"
@@ -129,11 +133,6 @@ private enum SyncDatabaseScope {
129133 case sharedDatabase
130134}
131135
132private struct SharedRecord<Value> {
133 var record: CKRecord
134 var value: Value
135}
136
137136private struct MergedSyncPayload {
138137 var payload: SyncPayload
139138 var hadConflict: Bool
@@ -154,6 +153,7 @@ final class CloudSyncService {
154153 private var container: CKContainer?
155154 private var privateDatabase: CKDatabase?
156155 private var sharedDatabase: CKDatabase?
156 private var didEnsureZone = false
157157 private let defaults: UserDefaults
158158 private let encoder: JSONEncoder
159159 private let decoder: JSONDecoder
@@ -359,6 +359,8 @@ final class CloudSyncService {
359359 throw CloudSyncRuntimeError.missingEntitlement
360360 }
361361
362 try await ensureCustomZone(in: database)
363
362364 switch entity {
363365 case .trackedDomain(let domain):
364366 let normalized = Self.normalizeDomain(domain)
@@ -543,31 +545,19 @@ final class CloudSyncService {
543545 }
544546
545547 private func fetchRemotePayload() async throws -> SyncPayload {
546 let privateTrackedDomains = try await fetchTrackedDomainRecords(in: .privateDatabase)
547 let sharedTrackedDomains = try await fetchTrackedDomainRecords(in: .sharedDatabase)
548 let privateWorkflows = try await fetchWorkflowRecords(in: .privateDatabase)
549 let sharedWorkflows = try await fetchWorkflowRecords(in: .sharedDatabase)
550
551 let appSettingsRecords: [SyncedAppSettings] = try await fetchRecords(
552 ofType: CloudRecordType.appSettings,
553 in: .privateDatabase
554 )
555 let monitoringSettingsRecords: [SyncedMonitoringSettings] = try await fetchRecords(
556 ofType: CloudRecordType.monitoringSettings,
557 in: .privateDatabase
558 )
559 let domainNotes: [SyncedDomainNote] = try await fetchRecords(
560 ofType: CloudRecordType.domainNote,
561 in: .privateDatabase
562 )
563 let historyMetadata: [SyncedHistoryMetadata] = try await fetchRecords(
564 ofType: CloudRecordType.historyMetadata,
565 in: .privateDatabase
566 )
567 let tombstones: [SyncTombstone] = try await fetchRecords(
568 ofType: CloudRecordType.tombstone,
569 in: .privateDatabase
570 )
548 let privateRecords = try await fetchZoneRecords(in: .privateDatabase)
549 let sharedRecords = try await fetchZoneRecords(in: .sharedDatabase)
550
551 let privateTrackedDomains = try await trackedDomains(from: privateRecords, in: .privateDatabase)
552 let sharedTrackedDomains = try await trackedDomains(from: sharedRecords, in: .sharedDatabase)
553 let privateWorkflows = try await workflows(from: privateRecords, in: .privateDatabase)
554 let sharedWorkflows = try await workflows(from: sharedRecords, in: .sharedDatabase)
555
556 let appSettingsRecords: [SyncedAppSettings] = decodeRecords(privateRecords, ofType: CloudRecordType.appSettings)
557 let monitoringSettingsRecords: [SyncedMonitoringSettings] = decodeRecords(privateRecords, ofType: CloudRecordType.monitoringSettings)
558 let domainNotes: [SyncedDomainNote] = decodeRecords(privateRecords, ofType: CloudRecordType.domainNote)
559 let historyMetadata: [SyncedHistoryMetadata] = decodeRecords(privateRecords, ofType: CloudRecordType.historyMetadata)
560 let tombstones: [SyncTombstone] = decodeRecords(privateRecords, ofType: CloudRecordType.tombstone)
571561
572562 let appSettings = appSettingsRecords.max(by: { $0.updatedAt < $1.updatedAt })
573563 ?? SyncedAppSettings(snapshot: DomainDataPortabilityService.loadAppSettings(), updatedAt: .distantPast)
@@ -590,6 +580,8 @@ final class CloudSyncService {
590580 throw CloudSyncRuntimeError.missingEntitlement
591581 }
592582
583 try await ensureCustomZone(in: privateDatabase)
584
593585 var privateRecordsToSave: [CKRecord] = []
594586 var sharedRecordsToSave: [CKRecord] = []
595587 var recordIDsToDelete: [CKRecord.ID] = []
@@ -626,11 +618,11 @@ final class CloudSyncService {
626618 switch tombstone.entityType {
627619 case .trackedDomain:
628620 let identifier = tombstone.identifier
629 recordIDsToDelete.append(CKRecord.ID(recordName: trackedDomainRecordName(for: identifier)))
630 recordIDsToDelete.append(CKRecord.ID(recordName: domainNoteRecordName(for: identifier)))
631 recordIDsToDelete.append(CKRecord.ID(recordName: historyMetadataRecordName(for: identifier)))
621 recordIDsToDelete.append(CKRecord.ID(recordName: trackedDomainRecordName(for: identifier), zoneID: customZoneID))
622 recordIDsToDelete.append(CKRecord.ID(recordName: domainNoteRecordName(for: identifier), zoneID: customZoneID))
623 recordIDsToDelete.append(CKRecord.ID(recordName: historyMetadataRecordName(for: identifier), zoneID: customZoneID))
632624 case .workflow:
633 recordIDsToDelete.append(CKRecord.ID(recordName: workflowRecordName(for: tombstone.identifier)))
625 recordIDsToDelete.append(CKRecord.ID(recordName: workflowRecordName(for: tombstone.identifier), zoneID: customZoneID))
634626 }
635627 }
636628
@@ -1051,7 +1043,12 @@ final class CloudSyncService {
10511043 }
10521044 }
10531045
1054 private func fetchRecords<T: Decodable>(ofType recordType: String, in scope: SyncDatabaseScope) async throws -> [T] {
1046 /// Fetches every record in `scope` via `CKFetchRecordZoneChangesOperation`,
1047 /// which — unlike a `CKQuery` — needs no queryable schema indexes and works
1048 /// on a brand-new zone. The private database holds the user's own records in
1049 /// a single custom zone; the shared database exposes one zone per accepted
1050 /// share, so every shared zone is fetched.
1051 private func fetchZoneRecords(in scope: SyncDatabaseScope) async throws -> [CKRecord] {
10551052 guard let database = cloudKitDatabase(in: scope) else {
10561053 if scope == .sharedDatabase {
10571054 return []
@@ -1059,99 +1056,108 @@ final class CloudSyncService {
10591056 throw CloudSyncRuntimeError.missingEntitlement
10601057 }
10611058
1062 let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true))
1063 var results: [T] = []
1064 var cursor: CKQueryOperation.Cursor?
1065
1066 repeat {
1067 let batch: (matchResults: [(CKRecord.ID, Result<CKRecord, any Error>)], queryCursor: CKQueryOperation.Cursor?)
1068 if let cursor {
1069 batch = try await database.records(continuingMatchFrom: cursor, desiredKeys: nil, resultsLimit: 200)
1070 } else {
1071 batch = try await database.records(matching: query, inZoneWith: nil, desiredKeys: nil, resultsLimit: 200)
1072 }
1073
1074 for (_, result) in batch.matchResults {
1075 switch result {
1076 case .success(let record):
1077 guard let payload = record[CloudRecordKey.payload] as? Data else { continue }
1078 if let decoded = try? decoder.decode(T.self, from: payload) {
1079 results.append(decoded)
1080 } else {
1081 lastErrorMessage = "Some iCloud records were skipped because they could not be decoded."
1082 }
1083 case .failure:
1084 continue
1085 }
1086 }
1087
1088 cursor = batch.queryCursor
1089 } while cursor != nil
1059 let zoneIDs: [CKRecordZone.ID]
1060 switch scope {
1061 case .privateDatabase:
1062 try await ensureCustomZone(in: database)
1063 zoneIDs = [customZoneID]
1064 case .sharedDatabase:
1065 zoneIDs = try await database.allRecordZones().map(\.zoneID)
1066 }
10901067
1091 return results
1068 guard !zoneIDs.isEmpty else { return [] }
1069 return try await fetchZoneChanges(in: database, zoneIDs: zoneIDs)
10921070 }
10931071
1094 private func fetchSharedRecords<T: Decodable>(
1095 ofType recordType: String,
1096 in scope: SyncDatabaseScope
1097 ) async throws -> [SharedRecord<T>] {
1098 guard let database = cloudKitDatabase(in: scope) else {
1099 if scope == .sharedDatabase {
1100 return []
1101 }
1102 throw CloudSyncRuntimeError.missingEntitlement
1072 private func fetchZoneChanges(in database: CKDatabase, zoneIDs: [CKRecordZone.ID]) async throws -> [CKRecord] {
1073 // A nil change token requests every record in the zone. The server may
1074 // truncate a large response and set `moreComing`, so keep re-fetching
1075 // the still-truncated zones with their latest tokens until all drain.
1076 var tokensByZone: [CKRecordZone.ID: CKServerChangeToken?] = [:]
1077 for zoneID in zoneIDs {
1078 tokensByZone[zoneID] = .some(nil)
11031079 }
11041080
1105 let query = CKQuery(recordType: recordType, predicate: NSPredicate(value: true))
1106 var results: [SharedRecord<T>] = []
1107 var cursor: CKQueryOperation.Cursor?
1108
1109 repeat {
1110 let batch: (matchResults: [(CKRecord.ID, Result<CKRecord, any Error>)], queryCursor: CKQueryOperation.Cursor?)
1111 if let cursor {
1112 batch = try await database.records(continuingMatchFrom: cursor, desiredKeys: nil, resultsLimit: 200)
1113 } else {
1114 batch = try await database.records(matching: query, inZoneWith: nil, desiredKeys: nil, resultsLimit: 200)
1115 }
1081 var records: [CKRecord] = []
1082 while !tokensByZone.isEmpty {
1083 tokensByZone = try await withCheckedThrowingContinuation { continuation in
1084 var moreByZone: [CKRecordZone.ID: CKServerChangeToken?] = [:]
1085 let configurations = tokensByZone.mapValues { token -> CKFetchRecordZoneChangesOperation.ZoneConfiguration in
1086 let configuration = CKFetchRecordZoneChangesOperation.ZoneConfiguration()
1087 configuration.previousServerChangeToken = token ?? nil
1088 return configuration
1089 }
11161090
1117 for (_, result) in batch.matchResults {
1118 switch result {
1119 case .success(let record):
1120 guard let payload = record[CloudRecordKey.payload] as? Data else { continue }
1121 if let decoded = try? decoder.decode(T.self, from: payload) {
1122 results.append(SharedRecord(record: record, value: decoded))
1123 } else {
1124 lastErrorMessage = "Some iCloud records were skipped because they could not be decoded."
1091 let operation = CKFetchRecordZoneChangesOperation(
1092 recordZoneIDs: Array(tokensByZone.keys),
1093 configurationsByRecordZoneID: configurations
1094 )
1095 operation.recordWasChangedBlock = { _, result in
1096 if case .success(let record) = result {
1097 records.append(record)
11251098 }
1126 case .failure:
1127 continue
11281099 }
1100 operation.recordZoneFetchResultBlock = { zoneID, result in
1101 if case .success(let value) = result, value.moreComing {
1102 moreByZone[zoneID] = .some(value.serverChangeToken)
1103 }
1104 }
1105 operation.fetchRecordZoneChangesResultBlock = { result in
1106 switch result {
1107 case .success:
1108 continuation.resume(returning: moreByZone)
1109 case .failure(let error):
1110 continuation.resume(throwing: error)
1111 }
1112 }
1113 database.add(operation)
11291114 }
1115 }
11301116
1131 cursor = batch.queryCursor
1132 } while cursor != nil
1117 return records
1118 }
11331119
1120 private func decodeRecords<T: Decodable>(_ records: [CKRecord], ofType recordType: String) -> [T] {
1121 var results: [T] = []
1122 for record in records where record.recordType == recordType {
1123 guard let payload = record[CloudRecordKey.payload] as? Data else { continue }
1124 if let decoded = try? decoder.decode(T.self, from: payload) {
1125 results.append(decoded)
1126 } else {
1127 lastErrorMessage = "Some iCloud records were skipped because they could not be decoded."
1128 }
1129 }
11341130 return results
11351131 }
11361132
1137 private func fetchTrackedDomainRecords(in scope: SyncDatabaseScope) async throws -> [TrackedDomain] {
1138 let records: [SharedRecord<TrackedDomain>] = try await fetchSharedRecords(ofType: CloudRecordType.trackedDomain, in: scope)
1139 let shares = try await sharesByRecordID(for: records.map(\.record), in: scope)
1133 private func trackedDomains(from records: [CKRecord], in scope: SyncDatabaseScope) async throws -> [TrackedDomain] {
1134 let matching = records.filter { $0.recordType == CloudRecordType.trackedDomain }
1135 let shares = try await sharesByRecordID(for: matching, in: scope)
11401136
1141 return records.map { entry in
1142 var trackedDomain = Self.payloadTrackedDomain(entry.value)
1143 trackedDomain.collaboration = collaborationMetadata(for: entry.record, share: shares[entry.record.recordID], scope: scope)
1137 return matching.compactMap { record in
1138 guard let payload = record[CloudRecordKey.payload] as? Data,
1139 let decoded = try? decoder.decode(TrackedDomain.self, from: payload) else {
1140 lastErrorMessage = "Some iCloud records were skipped because they could not be decoded."
1141 return nil
1142 }
1143 var trackedDomain = Self.payloadTrackedDomain(decoded)
1144 trackedDomain.collaboration = collaborationMetadata(for: record, share: shares[record.recordID], scope: scope)
11441145 return trackedDomain
11451146 }
11461147 }
11471148
1148 private func fetchWorkflowRecords(in scope: SyncDatabaseScope) async throws -> [DomainWorkflow] {
1149 let records: [SharedRecord<DomainWorkflow>] = try await fetchSharedRecords(ofType: CloudRecordType.workflow, in: scope)
1150 let shares = try await sharesByRecordID(for: records.map(\.record), in: scope)
1149 private func workflows(from records: [CKRecord], in scope: SyncDatabaseScope) async throws -> [DomainWorkflow] {
1150 let matching = records.filter { $0.recordType == CloudRecordType.workflow }
1151 let shares = try await sharesByRecordID(for: matching, in: scope)
11511152
1152 return records.map { entry in
1153 var workflow = normalizedWorkflow(entry.value)
1154 workflow.collaboration = collaborationMetadata(for: entry.record, share: shares[entry.record.recordID], scope: scope)
1153 return matching.compactMap { record in
1154 guard let payload = record[CloudRecordKey.payload] as? Data,
1155 let decoded = try? decoder.decode(DomainWorkflow.self, from: payload) else {
1156 lastErrorMessage = "Some iCloud records were skipped because they could not be decoded."
1157 return nil
1158 }
1159 var workflow = normalizedWorkflow(decoded)
1160 workflow.collaboration = collaborationMetadata(for: record, share: shares[record.recordID], scope: scope)
11551161 return workflow
11561162 }
11571163 }
@@ -1318,7 +1324,7 @@ final class CloudSyncService {
13181324 payload: T,
13191325 extraFields: [String: CKRecordValue]
13201326 ) -> CKRecord {
1321 let record = CKRecord(recordType: type, recordID: CKRecord.ID(recordName: recordName))
1327 let record = CKRecord(recordType: type, recordID: CKRecord.ID(recordName: recordName, zoneID: customZoneID))
13221328 record[CloudRecordKey.payload] = (try? encoder.encode(payload)) as CKRecordValue?
13231329 for (key, value) in extraFields {
13241330 record[key] = value
@@ -1393,6 +1399,22 @@ final class CloudSyncService {
13931399 return container
13941400 }
13951401
1402 /// The user's own records live in a single custom zone. A custom zone (as
1403 /// opposed to the default zone) is what lets the sync read every record with
1404 /// `CKFetchRecordZoneChangesOperation`, which needs no queryable schema
1405 /// indexes, and is also a prerequisite for CloudKit sharing.
1406 private var customZoneID: CKRecordZone.ID {
1407 CKRecordZone.ID(zoneName: CloudZone.name, ownerName: CKCurrentUserDefaultName)
1408 }
1409
1410 /// Creates the custom zone if it does not exist yet. Saving an existing zone
1411 /// is a no-op, so this is safe to call before every push and fetch.
1412 private func ensureCustomZone(in database: CKDatabase) async throws {
1413 if didEnsureZone { return }
1414 _ = try await database.modifyRecordZones(saving: [CKRecordZone(zoneID: customZoneID)], deleting: [])
1415 didEnsureZone = true
1416 }
1417
13961418 private func cloudKitDatabase(in scope: SyncDatabaseScope) -> CKDatabase? {
13971419 switch scope {
13981420 case .privateDatabase: