├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Harmony
├── CKRecordCoder
│ ├── CKRecordDecoder.swift
│ ├── CKRecordEncoder.swift
│ ├── CKRecordEncodingError.swift
│ ├── CKRecordKeyedDecodingContainer.swift
│ ├── CKRecordKeyedEncodingContainer.swift
│ ├── CKRecordSingleValueDecoder.swift
│ ├── CKRecordSingleValueEncoder.swift
│ ├── CloudKitSystemFieldsKeyName.swift
│ └── URLTransformer.swift
├── Core
│ ├── Configuration.swift
│ ├── Harmonic+CRUD.swift
│ ├── Harmonic+DatabaseDefaults.swift
│ ├── Harmonic.swift
│ ├── Keys.swift
│ ├── Logger.swift
│ └── Record.swift
├── Extensions
│ ├── CKRecordID.swift
│ └── StateSerialization+Encoder.swift
├── Harmony.swift
└── PrivacyInfo.xcprivacy
├── LICENSE.md
├── Package.resolved
├── Package.swift
├── README.md
└── Tests
└── HarmonyTests
└── HarmonyTests.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | xcuserdata/
5 | DerivedData/
6 | .swiftpm/configuration/registries.json
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 | .netrc
9 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Harmony/CKRecordCoder/CKRecordDecoder.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | public final class CKRecordDecoder {
5 |
6 | public func decode(_ type: T.Type, from record: CKRecord) throws -> T {
7 | let decoder = _CKRecordDecoder(record: record)
8 | return try T(from: decoder)
9 | }
10 |
11 | public init() {}
12 | }
13 |
14 | final class _CKRecordDecoder {
15 | var codingPath: [CodingKey] = []
16 | var userInfo: [CodingUserInfoKey: Any] = [:]
17 |
18 | private var record: CKRecord
19 |
20 | init(record: CKRecord) {
21 | self.record = record
22 | }
23 | }
24 |
25 | extension _CKRecordDecoder: Decoder {
26 |
27 | func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer {
28 | let container = CKRecordKeyedDecodingContainer(record: record)
29 | return KeyedDecodingContainer(container)
30 | }
31 |
32 | func unkeyedContainer() throws -> UnkeyedDecodingContainer {
33 | fatalError("Not implemented")
34 | }
35 |
36 | func singleValueContainer() throws -> SingleValueDecodingContainer {
37 | fatalError("No implemented")
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Harmony/CKRecordCoder/CKRecordEncoder.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | public final class CKRecordEncoder {
5 | // The maximum amount of data that can be stored by a record (1 MB)
6 | private let maximumAllowedRecordSizeInBytes: Int = 1 * 1024 * 1024
7 |
8 | public var zoneID: CKRecordZone.ID
9 |
10 | public init(zoneID: CKRecordZone.ID) {
11 | self.zoneID = zoneID
12 | }
13 |
14 | public func encode(_ value: E) throws -> CKRecord {
15 | let type = E.recordType
16 | let recordName = value.recordID.recordName
17 |
18 | let encoder = _CKRecordEncoder(
19 | recordTypeName: type,
20 | recordName: recordName,
21 | zoneID: zoneID
22 | )
23 |
24 | try value.encode(to: encoder)
25 |
26 | let record = encoder.buildRecord()
27 |
28 | try validateSize(for: encoder.storage.keys)
29 |
30 | return record
31 | }
32 |
33 | public static func decodeSystemFields(with systemFields: Data) -> CKRecord? {
34 | guard let coder = try? NSKeyedUnarchiver(forReadingFrom: systemFields) else { return nil }
35 | coder.requiresSecureCoding = true
36 | let record = CKRecord(coder: coder)
37 | coder.finishDecoding()
38 | return record
39 | }
40 |
41 | private func validateSize(for recordKeyValues: [String: CKRecordValue]) throws {
42 | guard
43 | let recordData = try? NSKeyedArchiver.archivedData(
44 | withRootObject: recordKeyValues,
45 | requiringSecureCoding: true
46 | )
47 | else { return }
48 |
49 | if recordData.count >= maximumAllowedRecordSizeInBytes {
50 | let context = EncodingError.Context(
51 | codingPath: [],
52 | debugDescription:
53 | "CKRecord is too large. Record is \(formattedSize(ofDataCount: recordData.count)), the maxmimum allowed size is \(formattedSize(ofDataCount: maximumAllowedRecordSizeInBytes)))"
54 | )
55 | throw EncodingError.invalidValue(Any.self, context)
56 | }
57 | }
58 |
59 | private func formattedSize(ofDataCount dataCount: Int) -> String {
60 | let formatter = ByteCountFormatter()
61 | formatter.allowedUnits = [.useKB, .useMB]
62 | formatter.countStyle = .binary
63 | return formatter.string(fromByteCount: Int64(dataCount))
64 | }
65 | }
66 |
67 | final class _CKRecordEncoder {
68 | let recordTypeName: CKRecord.RecordType
69 | let recordName: String
70 | let zoneID: CKRecordZone.ID
71 | var codingPath: [CodingKey] = []
72 | var userInfo: [CodingUserInfoKey: Any] = [:]
73 |
74 | var storage: Storage
75 |
76 | init(
77 | recordTypeName: CKRecord.RecordType,
78 | recordName: String,
79 | zoneID: CKRecordZone.ID,
80 | storage: Storage = Storage()
81 | ) {
82 | self.recordTypeName = recordTypeName
83 | self.recordName = recordName
84 | self.zoneID = zoneID
85 | self.storage = storage
86 | }
87 | }
88 |
89 | extension _CKRecordEncoder {
90 | final class Storage {
91 | private(set) var record: CKRecord?
92 | private(set) var keys: [String: CKRecordValue] = [:]
93 |
94 | func set(record: CKRecord?) {
95 | self.record = record
96 | }
97 |
98 | func encode(codingPath: [CodingKey], value: CKRecordValue?) {
99 | let key =
100 | codingPath
101 | .map { $0.stringValue }
102 | .joined(separator: "_")
103 | keys[key] = value
104 | }
105 | }
106 |
107 | func buildRecord() -> CKRecord {
108 | let output: CKRecord =
109 | storage.record
110 | ?? CKRecord(
111 | recordType: recordTypeName,
112 | recordID: CKRecord.ID(
113 | recordName: recordName,
114 | zoneID: zoneID)
115 | )
116 |
117 | guard output.recordType == recordTypeName else {
118 | fatalError(
119 | """
120 | CloudKit record type mismatch: the record should be of type \(recordTypeName) but it was
121 | of type \(output.recordType). This is probably a result of corrupted cloudKitSystemData
122 | or a change in record/type name that must be corrected in your type by adopting CustomCloudKitEncodable.
123 | """
124 | )
125 | }
126 |
127 | storage.keys.forEach { (key, value) in output[key] = value }
128 | return output
129 | }
130 | }
131 |
132 | extension _CKRecordEncoder: Encoder {
133 |
134 | func container(keyedBy type: Key.Type) -> KeyedEncodingContainer {
135 | let container = CKRecordKeyedEncodingContainer(storage: storage)
136 | container.codingPath = codingPath
137 | return KeyedEncodingContainer(container)
138 | }
139 |
140 | func unkeyedContainer() -> UnkeyedEncodingContainer {
141 | fatalError("Not implemented")
142 | }
143 |
144 | func singleValueContainer() -> SingleValueEncodingContainer {
145 | fatalError("Not implemented")
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/Harmony/CKRecordCoder/CKRecordEncodingError.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | enum CKRecordEncodingError: Error {
4 | case unsupportedValueForKey(String)
5 | case systemFieldsDecode(String)
6 | case referencesNotSupported(String)
7 |
8 | public var localizedDescription: String {
9 | switch self {
10 | case .unsupportedValueForKey(let key):
11 | return """
12 | The value of key \(key) is not supported. Only values that can be converted to
13 | CKRecordValue are supported. Check the CloudKit documentation to see which types
14 | can be used.
15 | """
16 | case .systemFieldsDecode(let info):
17 | return "Failed to process \(_CloudKitSystemFieldsKeyName): \(info)"
18 | case .referencesNotSupported(let key):
19 | return "References are not supported by CKRecordEncoder yet. Key \(key)."
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Harmony/CKRecordCoder/CKRecordKeyedDecodingContainer.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | final class CKRecordKeyedDecodingContainer {
5 | var record: CKRecord
6 | var codingPath: [CodingKey] = []
7 | var userInfo: [CodingUserInfoKey: Any] = [:]
8 | lazy var jsonDecoder: JSONDecoder = {
9 | return JSONDecoder()
10 | }()
11 |
12 | init(record: CKRecord) {
13 | self.record = record
14 | }
15 |
16 | private lazy var systemFieldsData: Data = {
17 | return encodeSystemFields()
18 | }()
19 |
20 | func nestedCodingPath(forKey key: CodingKey) -> [CodingKey] {
21 | return self.codingPath + [key]
22 | }
23 | }
24 |
25 | extension CKRecordKeyedDecodingContainer: KeyedDecodingContainerProtocol {
26 | var allKeys: [Key] {
27 | return self.record.allKeys().compactMap { Key(stringValue: $0) }
28 | }
29 |
30 | func contains(_ key: Key) -> Bool {
31 | // CKRecord does not contain a key that represents the system field information. The system fields data
32 | // must be extracted separately. Returning true here tells the decoder that we can extract this value.
33 | guard key.stringValue != _CloudKitSystemFieldsKeyName else { return true }
34 |
35 | // All other keys must be present in the CKRecord in order to be decoded.
36 | return allKeys.contains(where: { $0.stringValue == key.stringValue })
37 | }
38 |
39 | func decodeNil(forKey key: Key) throws -> Bool {
40 | if key.stringValue == _CloudKitSystemFieldsKeyName {
41 | return systemFieldsData.count == 0
42 | } else {
43 | return record[key.stringValue] == nil
44 | }
45 | }
46 |
47 | func decode(_ type: T.Type, forKey key: Key) throws -> T {
48 | // Extract system fields data from CKRecord.
49 | if key.stringValue == _CloudKitSystemFieldsKeyName {
50 | return systemFieldsData as! T
51 | } else if type == URL.self {
52 | return try URLTransformer.decodeSingle(record: record, key: key, codingPath: codingPath) as! T
53 | } else if type == [URL].self {
54 | return try URLTransformer.decodeMany(record: record, key: key, codingPath: codingPath) as! T
55 | } else if let value = record[key.stringValue] as? T {
56 | return value
57 | } else if let value = record[key.stringValue] as? Data,
58 | let decodedValue = try? jsonDecoder.decode(type, from: value)
59 | {
60 | return decodedValue
61 | }
62 |
63 | let decoder = CKRecordSingleValueDecoder(record: record, codingPath: codingPath + [key])
64 | guard let decodedValue = try? type.init(from: decoder) else {
65 | let context = DecodingError.Context(
66 | codingPath: codingPath,
67 | debugDescription: "Value could not be decoded for key \(key)."
68 | )
69 | throw DecodingError.typeMismatch(type, context)
70 | }
71 |
72 | return decodedValue
73 | }
74 |
75 | func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws
76 | -> KeyedDecodingContainer
77 | {
78 | fatalError("Not implemented")
79 | }
80 |
81 | func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer {
82 | fatalError("Not implemented")
83 | }
84 |
85 | func superDecoder() throws -> Decoder {
86 | return _CKRecordDecoder(record: record)
87 | }
88 |
89 | func superDecoder(forKey key: Key) throws -> Decoder {
90 | let decoder = _CKRecordDecoder(record: record)
91 | decoder.codingPath = [key]
92 | return decoder
93 | }
94 |
95 | private func encodeSystemFields() -> Data {
96 | let coder = NSKeyedArchiver(requiringSecureCoding: true)
97 | record.encodeSystemFields(with: coder)
98 | coder.finishEncoding()
99 | return coder.encodedData
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/Harmony/CKRecordCoder/CKRecordKeyedEncodingContainer.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | final class CKRecordKeyedEncodingContainer {
5 | var storage: _CKRecordEncoder.Storage
6 | var codingPath: [CodingKey] = []
7 | var userInfo: [CodingUserInfoKey: Any] = [:]
8 | lazy var jsonEncoder: JSONEncoder = {
9 | return JSONEncoder()
10 | }()
11 |
12 | init(storage: _CKRecordEncoder.Storage) {
13 | self.storage = storage
14 | }
15 | }
16 |
17 | extension CKRecordKeyedEncodingContainer: KeyedEncodingContainerProtocol {
18 | func encodeNil(forKey key: Key) throws {
19 | storage.encode(codingPath: codingPath + [key], value: nil)
20 | }
21 |
22 | func encode(_ value: T, forKey key: Key) throws where T: Encodable {
23 | // This breaks [URL] where the array is empty.
24 | guard !(value is CloudKitEncodable) &&
25 | !(value is [CloudKitEncodable]) else {
26 |
27 | throw CKRecordEncodingError.referencesNotSupported(
28 | codingPath.map { $0.stringValue }.joined(separator: "-"))
29 | }
30 |
31 | if key.stringValue == _CloudKitSystemFieldsKeyName {
32 | guard let systemFieldsData = value as? Data else {
33 | throw CKRecordEncodingError.systemFieldsDecode(
34 | "\(_CloudKitSystemFieldsKeyName) property must be of type Data.")
35 | }
36 | storage.set(record: CKRecordEncoder.decodeSystemFields(with: systemFieldsData))
37 | } else if let value = value as? URL {
38 | storage.encode(codingPath: codingPath + [key], value: URLTransformer.encode(value))
39 | } else if let value = value as? [URL] {
40 | storage.encode(
41 | codingPath: codingPath + [key], value: value.map(URLTransformer.encode) as CKRecordValue)
42 | } else if let value = value as? CKRecordValue {
43 | storage.encode(codingPath: codingPath + [key], value: value)
44 | } else {
45 | do {
46 | let encoder = CKRecordSingleValueEncoder(storage: storage, codingPath: codingPath + [key])
47 | try value.encode(to: encoder)
48 | } catch {
49 | storage.encode(
50 | codingPath: codingPath + [key], value: try jsonEncoder.encode(value) as CKRecordValue)
51 | }
52 | }
53 | }
54 |
55 | func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key)
56 | -> KeyedEncodingContainer where NestedKey: CodingKey
57 | {
58 | fatalError("Not implemented")
59 | }
60 |
61 | func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer {
62 | fatalError("Not implemented")
63 | }
64 |
65 | func superEncoder() -> Encoder {
66 | fatalError("Not implemented")
67 | }
68 |
69 | func superEncoder(forKey key: Key) -> Encoder {
70 | fatalError("Not implemented")
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/Harmony/CKRecordCoder/CKRecordSingleValueDecoder.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | enum CKRecordSingleValueDecodingError: Error {
5 | case codingPathMissing
6 | case unableToDecode
7 | }
8 |
9 | final class CKRecordSingleValueDecoder: Decoder {
10 | private var record: CKRecord
11 | var codingPath: [CodingKey]
12 | var userInfo: [CodingUserInfoKey: Any] = [:]
13 |
14 | init(record: CKRecord, codingPath: [CodingKey]) {
15 | self.record = record
16 | self.codingPath = codingPath
17 | }
18 |
19 | func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer
20 | where Key: CodingKey {
21 | return KeyedDecodingContainer(DummyKeyedDecodingContainer())
22 | }
23 |
24 | func unkeyedContainer() throws -> UnkeyedDecodingContainer {
25 | return DummyUnkeyedDecodingContainer()
26 | }
27 |
28 | func singleValueContainer() throws -> SingleValueDecodingContainer {
29 | var container = SingleCKRecordValueDecodingContainer(record: record)
30 | container.codingPath = codingPath
31 | return container
32 | }
33 | }
34 |
35 | struct SingleCKRecordValueDecodingContainer: SingleValueDecodingContainer {
36 | var codingPath: [CodingKey] = []
37 | var record: CKRecord
38 |
39 | func decodeNil() -> Bool {
40 | guard let key = codingPath.first else { return true }
41 | guard let _ = record[key.stringValue] else { return true }
42 | return false
43 | }
44 |
45 | func decode(_ type: T.Type) throws -> T where T: Decodable {
46 | guard let key = codingPath.first else {
47 | throw CKRecordSingleValueDecodingError.codingPathMissing
48 | }
49 | guard let value = record[key.stringValue] as? T else {
50 | throw CKRecordSingleValueDecodingError.unableToDecode
51 | }
52 | return value
53 | }
54 | }
55 |
56 | struct DummyKeyedDecodingContainer: KeyedDecodingContainerProtocol {
57 | var codingPath: [CodingKey] = []
58 | var allKeys: [Key] = []
59 |
60 | func contains(_ key: Key) -> Bool { return true }
61 |
62 | func decodeNil(forKey key: Key) throws -> Bool {
63 | throw CKRecordSingleValueDecodingError.unableToDecode
64 | }
65 |
66 | func decode(_ type: T.Type, forKey key: Key) throws -> T where T: Decodable {
67 | throw CKRecordSingleValueDecodingError.unableToDecode
68 | }
69 |
70 | func nestedContainer(
71 | keyedBy type: NestedKey.Type,
72 | forKey key: Key
73 | ) throws -> KeyedDecodingContainer where NestedKey: CodingKey {
74 | fatalError("Not implemented")
75 | }
76 |
77 | func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer {
78 | fatalError("Not implemented")
79 | }
80 |
81 | func superDecoder() throws -> Decoder { fatalError("Not implemented") }
82 |
83 | func superDecoder(forKey key: Key) throws -> Decoder { fatalError("Not implemented") }
84 | }
85 |
86 | struct DummyUnkeyedDecodingContainer: UnkeyedDecodingContainer {
87 | var codingPath: [CodingKey] = []
88 | var count: Int? = nil
89 | var isAtEnd: Bool = true
90 | var currentIndex: Int = 0
91 |
92 | mutating func decodeNil() throws -> Bool { throw CKRecordSingleValueDecodingError.unableToDecode }
93 |
94 | mutating func decode(_ type: T.Type) throws -> T where T: Decodable {
95 | throw CKRecordSingleValueDecodingError.unableToDecode
96 | }
97 |
98 | mutating func nestedContainer(
99 | keyedBy type: NestedKey.Type
100 | ) throws -> KeyedDecodingContainer where NestedKey: CodingKey {
101 | fatalError("Not implemented")
102 | }
103 |
104 | mutating func nestedUnkeyedContainer() throws -> UnkeyedDecodingContainer {
105 | fatalError("Not implemented")
106 | }
107 |
108 | mutating func superDecoder() throws -> Decoder { fatalError("Not implemented") }
109 | }
110 |
--------------------------------------------------------------------------------
/Harmony/CKRecordCoder/CKRecordSingleValueEncoder.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | enum CKRecordSingleValueEncodingError: Error {
5 | case unableToEncode
6 | }
7 |
8 | struct CKRecordSingleValueEncoder: Encoder {
9 | private var storage: _CKRecordEncoder.Storage
10 | var codingPath: [CodingKey]
11 | var userInfo: [CodingUserInfoKey: Any] = [:]
12 |
13 | init(storage: _CKRecordEncoder.Storage, codingPath: [CodingKey]) {
14 | self.storage = storage
15 | self.codingPath = codingPath
16 | }
17 |
18 | func container(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key: CodingKey {
19 | return KeyedEncodingContainer(DummyKeyedEncodingContainer())
20 | }
21 |
22 | func unkeyedContainer() -> UnkeyedEncodingContainer {
23 | return DummyUnkeyedCodingContainer()
24 | }
25 |
26 | func singleValueContainer() -> SingleValueEncodingContainer {
27 | var container = SingleCKRecordValueEncodingContainer(storage: storage)
28 | container.codingPath = codingPath
29 | return container
30 | }
31 | }
32 |
33 | struct SingleCKRecordValueEncodingContainer: SingleValueEncodingContainer {
34 | var storage: _CKRecordEncoder.Storage
35 | var codingPath: [CodingKey] = []
36 |
37 | mutating func encodeNil() throws {
38 | storage.encode(codingPath: codingPath, value: nil)
39 | }
40 |
41 | mutating func encode(_ value: T) throws where T: Encodable {
42 | guard let value = value as? CKRecordValue else {
43 | throw CKRecordSingleValueEncodingError.unableToEncode
44 | }
45 | storage.encode(codingPath: codingPath, value: value)
46 | }
47 | }
48 |
49 | struct DummyKeyedEncodingContainer: KeyedEncodingContainerProtocol {
50 | var codingPath: [CodingKey] = []
51 |
52 | mutating func encodeNil(forKey key: Key) throws {
53 | throw CKRecordSingleValueEncodingError.unableToEncode
54 | }
55 |
56 | mutating func encode(_ value: T, forKey key: Key) throws where T: Encodable {
57 | throw CKRecordSingleValueEncodingError.unableToEncode
58 | }
59 |
60 | mutating func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key)
61 | -> KeyedEncodingContainer where NestedKey: CodingKey
62 | {
63 | fatalError("Not implemented")
64 | }
65 |
66 | mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer {
67 | fatalError("Not implemented")
68 | }
69 |
70 | mutating func superEncoder() -> Encoder {
71 | fatalError("Not implemented")
72 | }
73 |
74 | mutating func superEncoder(forKey key: Key) -> Encoder {
75 | fatalError("Not implemented")
76 | }
77 | }
78 |
79 | struct DummyUnkeyedCodingContainer: UnkeyedEncodingContainer {
80 | var codingPath: [CodingKey] = []
81 | var count: Int = 0
82 |
83 | mutating func encodeNil() throws {
84 | throw CKRecordSingleValueEncodingError.unableToEncode
85 | }
86 |
87 | mutating func encode(_ value: T) throws where T: Encodable {
88 | throw CKRecordSingleValueEncodingError.unableToEncode
89 | }
90 |
91 | mutating func nestedContainer(keyedBy keyType: NestedKey.Type)
92 | -> KeyedEncodingContainer where NestedKey: CodingKey
93 | {
94 | fatalError("Not implemented")
95 | }
96 |
97 | mutating func nestedUnkeyedContainer() -> UnkeyedEncodingContainer {
98 | fatalError("Not implemented")
99 | }
100 |
101 | mutating func superEncoder() -> Encoder {
102 | fatalError("Not implemented")
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/Harmony/CKRecordCoder/CloudKitSystemFieldsKeyName.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | let _CloudKitSystemFieldsKeyName = "archivedRecordData"
4 |
--------------------------------------------------------------------------------
/Harmony/CKRecordCoder/URLTransformer.swift:
--------------------------------------------------------------------------------
1 | import CloudKit
2 | import Foundation
3 |
4 | enum URLTransformer {
5 | static func encode(_ value: URL) -> CKRecordValue {
6 | if value.isFileURL {
7 | return CKAsset(fileURL: value)
8 | } else {
9 | return value.absoluteString as CKRecordValue
10 | }
11 | }
12 |
13 | static func decodeMany(record: CKRecord, key: CodingKey, codingPath: [CodingKey]) throws -> [URL]
14 | {
15 | if let array = record[key.stringValue] as? [Any] {
16 | return try array.map { try decodeValue(value: $0, codingPath: codingPath) }
17 | }
18 | return []
19 | }
20 |
21 | static func decodeSingle(record: CKRecord, key: CodingKey, codingPath: [CodingKey]) throws -> URL
22 | {
23 | return try decodeValue(value: record[key.stringValue] as Any, codingPath: codingPath)
24 | }
25 |
26 | private static func decodeValue(value: Any, codingPath: [CodingKey]) throws -> URL {
27 | if let asset = value as? CKAsset {
28 | guard let url = asset.fileURL else {
29 | let context = DecodingError.Context(
30 | codingPath: codingPath, debugDescription: "CKAsset URL was nil.")
31 | throw DecodingError.valueNotFound(URL.self, context)
32 | }
33 | print("Decoding asset as \(url)")
34 |
35 | // Save a local copy, return that URL.
36 | // Never refer to the file URL somehow.
37 |
38 | return url
39 | }
40 |
41 | guard let str = value as? String else {
42 | let context = DecodingError.Context(
43 | codingPath: codingPath,
44 | debugDescription: "URL should have been encoded as String in CKRecord."
45 | )
46 | throw DecodingError.typeMismatch(URL.self, context)
47 | }
48 |
49 | guard let url = URL(string: str) else {
50 | let context = DecodingError.Context(
51 | codingPath: codingPath,
52 | debugDescription: "The string \(str) is not a valid url."
53 | )
54 | throw DecodingError.typeMismatch(URL.self, context)
55 | }
56 | return url
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/Harmony/Core/Configuration.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Configuration.swift
3 | // Harmony
4 | //
5 | // Created by Aaron Pearce on 8/06/23.
6 | //
7 |
8 | import Foundation
9 | import GRDB
10 |
11 | public struct Configuration: Sendable{
12 | let cloudKitContainerIdentifier: String?
13 | let sharedAppGroupContainerIdentifier: String?
14 | let databasePath: String?
15 | let databaseConfiguration: GRDB.Configuration?
16 |
17 | internal var isDummy: Bool = false
18 |
19 | public init(cloudKitContainerIdentifier: String? = nil, sharedAppGroupContainerIdentifier: String? = nil,
20 | databasePath: String? = nil,
21 | databaseConfiguration: GRDB.Configuration? = nil
22 | ) {
23 | self.cloudKitContainerIdentifier = cloudKitContainerIdentifier
24 | self.sharedAppGroupContainerIdentifier = sharedAppGroupContainerIdentifier
25 | self.databasePath = databasePath
26 | self.databaseConfiguration = databaseConfiguration
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/Harmony/Core/Harmonic+CRUD.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Harmonic+CRUD.swift
3 | // Harmony
4 | //
5 | // Created by Aaron Pearce on 11/06/23.
6 | //
7 |
8 | import Foundation
9 | import GRDB
10 | import CloudKit
11 | import os.log
12 |
13 | public extension Harmonic {
14 |
15 | func read(_ block: (Database) throws -> T) throws -> T {
16 | try reader.read(block)
17 | }
18 |
19 | func read(_ block: @Sendable @escaping (Database) throws -> T) async throws -> T {
20 | try await reader.read { db in
21 | try block(db)
22 | }
23 | }
24 |
25 | func create(record: T) async throws {
26 | try await database.write { db in
27 | try record.insert(db)
28 | }
29 |
30 | queueSaves(for: [record])
31 | }
32 |
33 | func create(records: [T]) async throws {
34 | try await database.write { db in
35 | try records.forEach {
36 | try $0.insert(db)
37 | }
38 | }
39 | queueSaves(for: records)
40 | }
41 |
42 | func save(record: T) async throws {
43 | try await database.write { db in
44 | try record.save(db)
45 | }
46 |
47 | queueSaves(for: [record])
48 | }
49 |
50 | func save(records: [T]) async throws {
51 | _ = try await database.write { db in
52 | try records.forEach {
53 | try $0.save(db)
54 | }
55 | }
56 |
57 | queueSaves(for: records)
58 | }
59 |
60 | func delete(record: T) async throws {
61 | _ = try await database.write { db in
62 | try record.delete(db)
63 | }
64 |
65 | queueDeletions(for: [record])
66 | }
67 |
68 | func delete(records: [T]) async throws {
69 | _ = try await database.write { db in
70 | try records.forEach {
71 | try $0.delete(db)
72 | }
73 | }
74 |
75 | queueDeletions(for: records)
76 | }
77 |
78 | /// Pushes all of the given record type to CloudKit
79 | /// This occurs regardless of changes.
80 | /// Sometimes used during migration for schema changes.
81 | func pushAll(for recordType: T.Type) throws {
82 | let records = try read { db in
83 | return try recordType.fetchAll(db)
84 | }
85 |
86 | queueSaves(for: records)
87 | }
88 |
89 | private func queueSaves(for records: [any HRecord]) {
90 | Logger.database.info("Queuing saves")
91 | let pendingSaves: [CKSyncEngine.PendingRecordZoneChange] = records.map {
92 | .saveRecord($0.recordID)
93 | }
94 |
95 | self.syncEngine.state.add(pendingRecordZoneChanges: pendingSaves)
96 | }
97 |
98 | private func queueDeletions(for records: [any HRecord]) {
99 | Logger.database.info("Queuing deletions")
100 | let pendingDeletions: [CKSyncEngine.PendingRecordZoneChange] = records.map {
101 | .deleteRecord($0.recordID)
102 | }
103 |
104 | self.syncEngine.state.add(pendingRecordZoneChanges: pendingDeletions)
105 | }
106 |
107 | func sendChanges() async throws {
108 | try await self.syncEngine.sendChanges()
109 | }
110 |
111 | func fetchChanges() async throws {
112 | try await self.syncEngine.fetchChanges()
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/Harmony/Core/Harmonic+DatabaseDefaults.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Harmonic+DatabaseDefaults.swift
3 | //
4 | //
5 | // Created by Aaron Pearce on 14/06/23.
6 | //
7 |
8 | import GRDB
9 | import os.log
10 | import Foundation
11 |
12 | extension Harmonic {
13 | static let sqlLogger = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "SQL")
14 |
15 | public static var defaultDatabasePath: String {
16 | get throws {
17 | let fileManager = FileManager.default
18 | let appSupportURL = try! fileManager.url(
19 | for: .applicationSupportDirectory, in: .userDomainMask,
20 | appropriateFor: nil, create: true)
21 | let directoryURL = appSupportURL.appendingPathComponent("Database", isDirectory: true)
22 |
23 | // Support for tests: delete the database if requested
24 | if CommandLine.arguments.contains("-reset") {
25 | try? fileManager.removeItem(at: directoryURL)
26 | }
27 |
28 | // Create the database folder if needed
29 | try! fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
30 |
31 | // Open or create the database
32 | let databaseURL = directoryURL.appendingPathComponent("db.sqlite")
33 |
34 | return databaseURL.path
35 | }
36 | }
37 |
38 |
39 | static func makeDatabaseConfiguration(_ base: GRDB.Configuration = GRDB.Configuration()) -> GRDB.Configuration {
40 | var config = base
41 |
42 | // An opportunity to add required custom SQL functions or
43 | // collations, if needed:
44 | // config.prepareDatabase { db in
45 | // db.add(function: ...)
46 | // }
47 |
48 | // Log SQL statements if the `SQL_TRACE` environment variable is set.
49 | // See
50 | if ProcessInfo.processInfo.environment["SQL_TRACE"] != nil {
51 | config.prepareDatabase { db in
52 | db.trace {
53 | // It's ok to log statements publicly. Sensitive
54 | // information (statement arguments) are not logged
55 | // unless config.publicStatementArguments is set
56 | // (see below).
57 | os_log("%{public}@", log: Harmonic.sqlLogger, type: .debug, String(describing: $0))
58 | }
59 | }
60 | }
61 |
62 | #if DEBUG
63 | // Protect sensitive information by enabling verbose debugging in
64 | // DEBUG builds only.
65 | // See
66 | config.publicStatementArguments = true
67 | #endif
68 |
69 | return config
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/Harmony/Core/Harmonic.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Harmony.swift
3 | // Harmony
4 | //
5 | // Created by Aaron Pearce on 8/06/23.
6 | //
7 |
8 | import SwiftUI
9 | import CloudKit
10 | import os.log
11 | import GRDB
12 | import Combine
13 |
14 | ///
15 | /// Harmony becomes your central repository.
16 | /// Every write and read goes via it.
17 | ///
18 | /// Existing GRDB users can pass in their database file path if they wish
19 | /// Harmony will take the following configuration options
20 | /// - recordTypes: [HRecord.Type]
21 | /// - configuration: Configuration
22 | /// - cloudKitContainerIdentifier: String
23 | /// - sharedAppGroupIdentifier: String
24 | /// - databasePath: String ??
25 | /// - databaseConfiguration: GRDB.Configuration
26 | /// - migrator: GRDB.Migrator
27 | ///
28 | /// Writing will be done via similar methods to the
29 | /// GRDB.DatabaseWriter protocol with syntatic sugar for
30 | /// HRecord.
31 | ///
32 | /// Harmony will then manage all syncing and DB management
33 | /// internally, removing the need for the system to observe
34 | /// the database in any manner and also removing the
35 | /// possiblity for a user to write directly to the database
36 | /// without inherently misusing the library.
37 | ///
38 | ///
39 | public final class Harmonic {
40 |
41 | // Containers
42 | // Shared or private?
43 | let configuration: Configuration
44 |
45 | /// The sync engine being used to sync.
46 | /// This is lazily initialized. You can re-initialize the sync engine by setting `_syncEngine` to nil then calling `self.syncEngine`.
47 | var _syncEngine: CKSyncEngine?
48 | var syncEngine: CKSyncEngine {
49 | if _syncEngine == nil {
50 | self.initializeSyncEngine()
51 | }
52 | return _syncEngine!
53 | }
54 |
55 | private let modelTypes: [any HRecord.Type]
56 | private let container: CKContainer
57 | private let userDefaults: UserDefaults
58 | let database: DatabaseWriter
59 |
60 | public var reader: DatabaseReader {
61 | database
62 | }
63 |
64 | public var databaseChanged: DatabasePublishers.DatabaseRegion {
65 | DatabaseRegionObservation(
66 | tracking: .fullDatabase
67 | ).publisher(in: database)
68 | }
69 |
70 | private var privateDatabase: CKDatabase {
71 | container.privateCloudDatabase
72 | }
73 |
74 | private var lastStateSerialization: CKSyncEngine.State.Serialization? {
75 | get {
76 | if let data = userDefaults.data(forKey: Keys.stateSerialization),
77 | let state = try? CKSyncEngine.State.Serialization.decode(data) {
78 | return state
79 | } else {
80 | return nil
81 | }
82 | }
83 | set {
84 | if let data = try? newValue?.encode() {
85 | userDefaults.set(data, forKey: Keys.stateSerialization)
86 | }
87 | }
88 | }
89 |
90 | public init(for modelTypes: [any HRecord.Type], configuration: Configuration, migrator: DatabaseMigrator) {
91 | self.modelTypes = modelTypes
92 | self.configuration = configuration
93 |
94 | if let cloudKitContainerIdentifier = configuration.cloudKitContainerIdentifier {
95 | self.container = CKContainer(identifier: cloudKitContainerIdentifier)
96 | } else {
97 | self.container = .default()
98 | }
99 |
100 | if let sharedAppGroupContainerIdentifier = configuration.sharedAppGroupContainerIdentifier {
101 | self.userDefaults = UserDefaults(suiteName: sharedAppGroupContainerIdentifier)!
102 | } else {
103 | self.userDefaults = .standard
104 | }
105 |
106 | var databaseHasMigrated = false
107 | if configuration.isDummy {
108 | self.database = try! DatabaseQueue()
109 | } else {
110 | do {
111 | let databasePath = try configuration.databasePath ?? Self.defaultDatabasePath
112 | let databaseConfiguration = configuration.databaseConfiguration ?? Self.makeDatabaseConfiguration()
113 |
114 | self.database = try DatabasePool(
115 | path: databasePath,
116 | configuration: databaseConfiguration
117 | )
118 |
119 | let initialMigrations = try self.database.read { db in
120 | return try migrator.appliedMigrations(db)
121 | }
122 |
123 | try migrator.migrate(self.database)
124 |
125 | let afterMigrations = try self.database.read { db in
126 | return try migrator.appliedMigrations(db)
127 | }
128 |
129 | // If any migration occurred, we'll sync the whole database to ensure any new default values sync too.
130 | if initialMigrations != afterMigrations {
131 | databaseHasMigrated = true
132 | }
133 | } catch {
134 | fatalError("Unresolved error \(error)")
135 | }
136 |
137 | // Lazily start.
138 | Task {
139 | initializeSyncEngine()
140 |
141 | if databaseHasMigrated {
142 | // Sync all entity types as an initial method, this can get smarter by only migrating those that have been altered.
143 | for modelType in modelTypes {
144 | try? pushAll(for: modelType)
145 | }
146 | }
147 |
148 | try? await syncEngine.fetchChanges()
149 | }
150 | }
151 | }
152 |
153 | static func dummy() -> Harmonic {
154 | var config = Configuration()
155 | config.isDummy = true
156 | let migrator = DatabaseMigrator()
157 | return Harmonic(for: [], configuration: config, migrator: migrator)
158 | }
159 | }
160 |
161 | private extension Harmonic {
162 |
163 | func initializeSyncEngine() {
164 | var configuration = CKSyncEngine.Configuration(
165 | database: self.container.privateCloudDatabase,
166 | stateSerialization: self.lastStateSerialization,
167 | delegate: self
168 | )
169 | configuration.automaticallySync = true //self.automaticallySync
170 | let syncEngine = CKSyncEngine(configuration)
171 | _syncEngine = syncEngine
172 | Logger.database.log("Initialized sync engine: \(syncEngine)")
173 | }
174 | }
175 |
176 | // MARK: CKSyncEngineDelegate
177 |
178 | extension Harmonic: CKSyncEngineDelegate {
179 |
180 | public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
181 |
182 | Logger.database.log("Handling event \(event, privacy: .public)")
183 |
184 | switch event {
185 | case .stateUpdate(let stateUpdate):
186 | self.lastStateSerialization = stateUpdate.stateSerialization
187 |
188 | case .accountChange(let event):
189 | self.handleAccountChange(event)
190 |
191 | case .fetchedDatabaseChanges(let event):
192 | self.handleFetchedDatabaseChanges(event)
193 |
194 | case .fetchedRecordZoneChanges(let event):
195 | self.handleFetchedRecordZoneChanges(event)
196 |
197 | case .sentRecordZoneChanges(let event):
198 | self.handleSentRecordZoneChanges(event)
199 |
200 | case .sentDatabaseChanges:
201 | // The sample app doesn't track sent database changes in any meaningful way, but this might be useful depending on your data model.
202 | break
203 |
204 | case .willFetchChanges, .willFetchRecordZoneChanges, .didFetchRecordZoneChanges, .didFetchChanges, .willSendChanges, .didSendChanges:
205 | // We don't do anything here in the sample app, but these events might be helpful if you need to do any setup/cleanup when sync starts/ends.
206 | break
207 |
208 | @unknown default:
209 | Logger.database.info("Received unknown event: \(event)")
210 | }
211 | }
212 |
213 | public func nextRecordZoneChangeBatch(_ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine) async -> CKSyncEngine.RecordZoneChangeBatch? {
214 | Logger.database.info("Returning next record change batch for context: \(context.description, privacy: .public)")
215 |
216 | let scope = context.options.scope
217 | let changes = syncEngine.state.pendingRecordZoneChanges.filter { scope.contains($0) }
218 |
219 | let batch = await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: changes) { recordID in
220 | if let recordType = recordID.parsedRecordType,
221 | let internalID = recordID.parsedRecordID {
222 | // We can sync this.
223 | // Find this in our DB
224 | if let modelType = modelType(for: recordType),
225 | let record = try? await database.read({ db in
226 | let uuid = UUID(uuidString: internalID)
227 | return try modelType.fetchOne(db, key: uuid)
228 | }) {
229 | return record.record
230 | } else {
231 | // Could be a deletion?
232 | syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
233 | return nil
234 | }
235 | } else {
236 | syncEngine.state.remove(pendingRecordZoneChanges: [.saveRecord(recordID)])
237 | return nil
238 | }
239 | }
240 |
241 | return batch
242 | }
243 | }
244 |
245 | // MARK: - Event Handlers
246 | private extension Harmonic {
247 |
248 | func handleAccountChange(_ event: CKSyncEngine.Event.AccountChange) {
249 | Logger.database.info("Handle account change \(event, privacy: .public)")
250 | }
251 |
252 | func handleFetchedDatabaseChanges(_ event: CKSyncEngine.Event.FetchedDatabaseChanges) {
253 | Logger.database.info("Handle fetched database changes \(event, privacy: .public)")
254 |
255 | // If a zone was deleted, we should delete everything for that zone locally.
256 | #warning("Zone deletion is not handled!")
257 | /* Copied from the example sync sample from Apple
258 | var needsToSave = false
259 | for deletion in event.deletions {
260 | switch deletion.zoneID.zoneName {
261 | case Contact.zoneName:
262 | self.appData.contacts = [:]
263 | needsToSave = true
264 | default:
265 | Logger.database.info("Received deletion for unknown zone: \(deletion.zoneID)")
266 | }
267 | }
268 |
269 | if needsToSave {
270 | try? self.persistLocalData() // This error should be handled, but we'll skip that for brevity in this sample app.
271 | }
272 | */
273 | }
274 |
275 | func handleFetchedRecordZoneChanges(_ event: CKSyncEngine.Event.FetchedRecordZoneChanges) {
276 | Logger.database.info("Handle fetched record zone changes \(event)")
277 |
278 | for modification in event.modifications {
279 | // The sync engine fetched a record, and we want to merge it into our local persistence.
280 | // If we already have this object locally, let's merge the data from the server.
281 | // Otherwise, let's create a new local object.
282 | let record = modification.record
283 | if let id = record.recordID.parsedRecordID,
284 | let modelType = modelType(for: record) {
285 | try! database.write { db in
286 | if var localRecord = try modelType.fetchOne(db, key: UUID(uuidString: id)) {
287 | try localRecord.updateChanges(db: db, ckRecord: record)
288 | } else {
289 | if let model = modelType.parseFrom(record: record) {
290 | try model.save(db)
291 | }
292 | }
293 | }
294 | }
295 | }
296 |
297 | for deletion in event.deletions {
298 |
299 | // A record was deleted on the server, so let's remove it from our local persistence.
300 | let recordID = deletion.recordID
301 | if let recordType = recordID.parsedRecordType,
302 | let id = recordID.parsedRecordID,
303 | let modelType = modelType(for: recordType) {
304 | // Find it locally and merge it
305 | _ = try! database.write { db in
306 | try modelType.deleteOne(db, key: UUID(uuidString: id))
307 | }
308 | }
309 | }
310 |
311 | // If we had any changes, let's save to disk.
312 | if !event.modifications.isEmpty || !event.deletions.isEmpty {
313 | // Already saved above... but maybe we should save at the end of a batch?
314 | }
315 | }
316 |
317 | func handleSentRecordZoneChanges(_ event: CKSyncEngine.Event.SentRecordZoneChanges) {
318 | Logger.database.info("Handle sent record zone changes \(event, privacy: .public)")
319 |
320 | // If we failed to save a record, we might want to retry depending on the error code.
321 | var newPendingRecordZoneChanges = [CKSyncEngine.PendingRecordZoneChange]()
322 | var newPendingDatabaseChanges = [CKSyncEngine.PendingDatabaseChange]()
323 |
324 | // Update the last known server record for each of the saved records.
325 | for savedRecord in event.savedRecords {
326 | if let id = savedRecord.recordID.parsedRecordID,
327 | let modelType = modelType(for: savedRecord) {
328 | try! database.write { db in
329 | var localRecord = try? modelType.fetchOne(db, key: UUID(uuidString: id))
330 | localRecord?.setLastKnownRecordIfNewer(savedRecord)
331 | try! localRecord?.save(db)
332 | }
333 | }
334 | }
335 |
336 | // Handle any failed record saves.
337 | for failedRecordSave in event.failedRecordSaves {
338 | let failedRecord = failedRecordSave.record
339 | guard let id = failedRecord.recordID.parsedRecordID,
340 | let modelType = modelType(for: failedRecord) else {
341 | continue
342 | }
343 |
344 | var shouldClearServerRecord = false
345 | switch failedRecordSave.error.code {
346 |
347 | case .serverRecordChanged:
348 | // Let's merge the record from the server into our own local copy.
349 | // The `mergeFromServerRecord` function takes care of the conflict resolution.
350 | guard let serverRecord = failedRecordSave.error.serverRecord else {
351 | Logger.database.error("No server record for conflict \(failedRecordSave.error)")
352 | continue
353 | }
354 |
355 | try? database.write { db in
356 | var localRecord = try modelType.fetchOne(db, key: UUID(uuidString: id))
357 | // Merge from server...
358 | try localRecord?.updateChanges(db: db, ckRecord: serverRecord)
359 | }
360 |
361 | newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID))
362 |
363 | case .zoneNotFound:
364 | // Looks like we tried to save a record in a zone that doesn't exist.
365 | // Let's save that zone and retry saving the record.
366 | // Also clear the last known server record if we have one, it's no longer valid.
367 | let zone = CKRecordZone(zoneID: failedRecord.recordID.zoneID)
368 | newPendingDatabaseChanges.append(.saveZone(zone))
369 | newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID))
370 | shouldClearServerRecord = true
371 |
372 | case .unknownItem:
373 | // We tried to save a record with a locally-cached server record, but that record no longer exists on the server.
374 | // This might mean that another device deleted the record, but we still have the data for that record locally.
375 | // We have the choice of either deleting the local data or re-uploading the local data.
376 | // For this sample app, let's re-upload the local data.
377 | newPendingRecordZoneChanges.append(.saveRecord(failedRecord.recordID))
378 | shouldClearServerRecord = true
379 |
380 | case .networkFailure, .networkUnavailable, .zoneBusy, .serviceUnavailable, .notAuthenticated, .operationCancelled:
381 | // There are several errors that the sync engine will automatically retry, let's just log and move on.
382 | Logger.database.debug("Retryable error saving \(failedRecord.recordID): \(failedRecordSave.error)")
383 |
384 | default:
385 | // We got an error, but we don't know what it is or how to handle it.
386 | // If you have any sort of telemetry system, you should consider tracking this scenario so you can understand which errors you see in the wild.
387 | Logger.database.fault("Unknown error saving record \(failedRecord.recordID): \(failedRecordSave.error)")
388 | }
389 |
390 | if shouldClearServerRecord {
391 | try? database.write { db in
392 | var localRecord = try? modelType.fetchOne(db, key: UUID(uuidString: id))
393 | // Merge from server...
394 | localRecord?.archivedRecord = nil
395 | try localRecord?.save(db)
396 | }
397 | }
398 | }
399 |
400 | if !newPendingDatabaseChanges.isEmpty {
401 | self.syncEngine.state.add(pendingDatabaseChanges: newPendingDatabaseChanges)
402 | }
403 |
404 | if !newPendingRecordZoneChanges.isEmpty {
405 | self.syncEngine.state.add(pendingRecordZoneChanges: newPendingRecordZoneChanges)
406 | }
407 | }
408 | }
409 |
410 |
411 | // MARK: - Model Type Helpers
412 |
413 | private extension Harmonic {
414 |
415 | func modelType(for record: CKRecord) -> (any HRecord.Type)? {
416 | return modelType(for: record.recordType)
417 | }
418 |
419 | func modelType(for recordType: String) -> (any HRecord.Type)? {
420 | guard let modelType = self.modelTypes.first(where: { t in
421 | t.recordType == recordType
422 | }) else {
423 | return nil
424 | }
425 |
426 | return modelType
427 | }
428 | }
429 |
--------------------------------------------------------------------------------
/Harmony/Core/Keys.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Keys.swift
3 | //
4 | //
5 | // Created by Aaron Pearce on 22/06/23.
6 | //
7 |
8 | import Foundation
9 |
10 | extension Harmonic {
11 | enum Keys {
12 | static let stateSerialization = "Harmony.State"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/Harmony/Core/Logger.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Logger.swift
3 | // Harmony
4 | //
5 | // Created by Aaron Pearce on 8/06/23.
6 | //
7 | import os.log
8 |
9 | extension Logger {
10 |
11 | static let loggingSubsystem: String = "com.pearcemedia.Harmony"
12 |
13 | static let database = Logger(subsystem: Self.loggingSubsystem, category: "Database")
14 | }
15 |
--------------------------------------------------------------------------------
/Harmony/Core/Record.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HRecord.swift
3 | // CloudSyncTest
4 | //
5 | // Created by Aaron Pearce on 14/07/21.
6 | //
7 |
8 | import GRDB
9 | import Foundation
10 | import CloudKit
11 |
12 | public protocol CloudKitEncodable: Encodable {}
13 |
14 | public protocol CloudKitDecodable: Decodable {}
15 |
16 | public protocol HRecord: CloudKitEncodable & CloudKitDecodable & FetchableRecord & PersistableRecord {
17 |
18 | // Required model values
19 | var id: UUID { get }
20 | // var isDeleted: Bool { get set }
21 |
22 | // CloudKit values
23 | var archivedRecordData: Data? { get set }
24 | var archivedRecord: CKRecord? { get }
25 |
26 | static var recordType: String { get }
27 | var zoneID: CKRecordZone.ID { get }
28 |
29 | var recordID: CKRecord.ID { get }
30 | var record: CKRecord { get }
31 |
32 | var cloudKitLastModifiedDate: Date? { get }
33 |
34 | static func parseFrom(record: CKRecord) -> Self?
35 |
36 | mutating func updateChanges(db: Database, ckRecord: CKRecord) throws
37 | }
38 |
39 | extension HRecord {
40 | public var archivedRecord: CKRecord? {
41 | get {
42 | guard let data = archivedRecordData,
43 | let unarchiver = try? NSKeyedUnarchiver(forReadingFrom: data) else {
44 | return nil
45 | }
46 |
47 | unarchiver.requiresSecureCoding = true
48 | return CKRecord(coder: unarchiver)
49 | }
50 | set {
51 | if let newValue {
52 | let archiver = NSKeyedArchiver(requiringSecureCoding: true)
53 | newValue.encodeSystemFields(with: archiver)
54 | self.archivedRecordData = archiver.encodedData
55 | } else {
56 | self.archivedRecordData = nil
57 | }
58 | }
59 | }
60 |
61 | public static var recordType: String {
62 | String(describing: self)
63 | }
64 |
65 | public var recordID: CKRecord.ID {
66 | CKRecord.ID(
67 | recordName: "\(Self.recordType)|\(id.uuidString)",
68 | zoneID: zoneID
69 | )
70 | }
71 |
72 | public var cloudKitLastModifiedDate: Date? {
73 | archivedRecord?.modificationDate
74 | }
75 |
76 | public var cloudKitCreationDate: Date? {
77 | archivedRecord?.creationDate
78 | }
79 |
80 | public static func parseFrom(record: CKRecord) -> Self? {
81 | try? CKRecordDecoder().decode(Self.self, from: record)
82 | }
83 |
84 | public mutating func updateChanges(db: Database, ckRecord: CKRecord) throws {
85 | if let cloudRecord = Self.parseFrom(record: ckRecord) {
86 | try cloudRecord.updateChanges(db, from: self)
87 | }
88 | }
89 |
90 | mutating func setLastKnownRecordIfNewer(_ otherRecord: CKRecord) {
91 | let localRecord = self.archivedRecord
92 | if let localDate = localRecord?.modificationDate {
93 | if let otherDate = otherRecord.modificationDate, localDate < otherDate {
94 | self.archivedRecord = otherRecord
95 | } else {
96 | // The other record is older than the one we already have.
97 | }
98 | } else {
99 | self.archivedRecord = otherRecord
100 | }
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/Harmony/Extensions/CKRecordID.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CKRecordID.swift
3 | // Harmony
4 | //
5 | // Created by Aaron Pearce on 11/06/23.
6 | //
7 |
8 | import Foundation
9 | import CloudKit
10 |
11 | extension CKRecord.ID {
12 | var parsedRecordType: String? {
13 | if let recordType = recordName
14 | .split(separator: "|", maxSplits: 1)
15 | .first {
16 | return String(recordType)
17 | } else {
18 | return nil
19 | }
20 | }
21 |
22 | var parsedRecordID: String? {
23 | if let recordID = recordName
24 | .split(separator: "|", maxSplits: 1)
25 | .last {
26 | return String(recordID)
27 | } else {
28 | return nil
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Harmony/Extensions/StateSerialization+Encoder.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StateSerialization+Encoder.swift
3 | //
4 | //
5 | // Created by Aaron Pearce on 22/06/23.
6 | //
7 |
8 | import CloudKit
9 |
10 | extension CKSyncEngine.State.Serialization {
11 |
12 | func encode() throws -> Data? {
13 | return try JSONEncoder().encode(self)
14 | }
15 |
16 | static func decode(_ data: Data) throws -> Self? {
17 | return try JSONDecoder().decode(Self.self, from: data)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Harmony/Harmony.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Harmony.swift
3 | //
4 | //
5 | // Created by Aaron Pearce on 14/06/23.
6 | //
7 |
8 | import Foundation
9 | import GRDB
10 |
11 | @propertyWrapper
12 | public struct Harmony {
13 | static var current: Harmonic = .dummy()
14 |
15 | public var wrappedValue: Harmonic {
16 | get { Self.current }
17 | }
18 |
19 | static var hasBeenInitialized = false
20 |
21 | public init() {
22 | if !Self.hasBeenInitialized {
23 | fatalError("Ensure your first usage of @Harmonic initializes the system.")
24 | }
25 | }
26 |
27 | public init(records modelTypes: [any HRecord.Type], configuration: Configuration, migrator: DatabaseMigrator) {
28 |
29 | guard !Self.hasBeenInitialized else {
30 | fatalError("Do not try to initialize @Harmonic twice!")
31 | }
32 |
33 | Self.current = Harmonic(for: modelTypes, configuration: configuration, migrator: migrator)
34 | Self.hasBeenInitialized = true
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/Harmony/PrivacyInfo.xcprivacy:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | NSPrivacyTracking
6 |
7 | NSPrivacyTrackingDomains
8 |
9 | NSPrivacyCollectedDataTypes
10 |
11 | NSPrivacyAccessedAPITypes
12 |
13 |
14 | NSPrivacyAccessedAPIType
15 | NSPrivacyAccessedAPICategoryUserDefaults
16 | NSPrivacyAccessedAPITypeReasons
17 |
18 | 1C8F.1
19 | CA92.1
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Aaron Pearce
4 |
5 | Portions of this library were taken and modified from [Cirrus](https://github.com/jayhickey/Cirrus) under the MIT license:
6 |
7 | Copyright (c) 2020 Jay Hickey
8 |
9 | Permission is hereby granted, free of charge, to any person obtaining a copy
10 | of this software and associated documentation files (the "Software"), to deal
11 | in the Software without restriction, including without limitation the rights
12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13 | copies of the Software, and to permit persons to whom the Software is
14 | furnished to do so, subject to the following conditions:
15 |
16 | The above copyright notice and this permission notice shall be included in all
17 | copies or substantial portions of the Software.
18 |
19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25 | SOFTWARE.
26 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "grdb.swift",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/groue/GRDB.swift",
7 | "state" : {
8 | "revision" : "77b85bed259b7f107710a0b78c439b1c5839dc45",
9 | "version" : "6.26.0"
10 | }
11 | }
12 | ],
13 | "version" : 2
14 | }
15 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "Harmony",
8 | platforms: [.iOS(.v17), .macOS(.v14), .watchOS(.v10), .macCatalyst(.v17)],
9 | products: [
10 | .library(
11 | name: "Harmony",
12 | targets: ["Harmony"]
13 | ),
14 | ],
15 | dependencies: [
16 | .package(url: "https://github.com/groue/GRDB.swift", from: "6.26.0"),
17 | ],
18 | targets: [
19 | .target(
20 | name: "Harmony",
21 | dependencies: [
22 | .product(name: "GRDB", package: "GRDB.swift")
23 | ],
24 | path: "Harmony",
25 | resources: [.copy("PrivacyInfo.xcprivacy")]
26 | ),
27 | .testTarget(
28 | name: "HarmonyTests",
29 | dependencies: ["Harmony"]
30 | ),
31 | ]
32 | )
33 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Harmony
2 |
3 | Harmony provides CloudKit sync for GRDB and only GRDB.
4 |
5 | **Requirements:** iOS 17.0+, macOS 14.0+, tvOS 17.0+, watchOS 10.0+.
6 |
7 | I am open to feedback to style, functionality and other contributions.
8 |
9 | ## Usage
10 |
11 | After installing via Swift Package Manager and enabling CloudKit for your app, add the following to your `App.swift` or equivalent to initialize Harmony.
12 |
13 | ```
14 | @Harmony(
15 | records: [Model1.self, Model2.self],
16 | configuration: Harmony.Configuration(
17 | cloudKitContainerIdentifier: "xxx" \\ Or leave nil for your default container.
18 | ),
19 | migrator: // Your GRDB DatabaseMigrator
20 | ) var harmony
21 | ```
22 |
23 | You can then access your Harmony instance from anywhere for writing or reading by simply using:
24 |
25 | ```
26 | @Harmony var harmony
27 | ```
28 |
29 | Harmony provides access to write methods that will automatically sync to CloudKit and hides direct database writing access for this purpose. If you write via another means to your GRDB database, Harmony will not see those changes.
30 |
31 | Harmony also provides direct access to your `DatabaseReader` if you wish to read directly. I highly suggest seeing if GRDBQuery will fit your needs first.
32 |
33 | ### Foreign Keys
34 |
35 | Harmony does not support usage of foreign keys in GRDB. This is due to CloudKit being a system of eventual consistency. Records can return in sets where they don't have the related data within the same set or out of order. Foreign keys are incompatible with this style of system so we have taken the choice to not support foreign keys after many attempts to do so.
36 |
--------------------------------------------------------------------------------
/Tests/HarmonyTests/HarmonyTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import Harmony
3 |
4 | final class HarmonyTests: XCTestCase {
5 | func testExample() throws {
6 | // XCTest Documentation
7 | // https://developer.apple.com/documentation/xctest
8 |
9 | // Defining Test Cases and Test Methods
10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
11 | }
12 | }
13 |
--------------------------------------------------------------------------------