diff --git a/.gitignore b/.gitignore
index d6a7bb00..6e91ebaf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ DerivedData/
*.orig
*.sqlite
*.xcresult
+Package.resolved
diff --git a/Package.swift b/Package.swift
index e1f278e8..5168e191 100644
--- a/Package.swift
+++ b/Package.swift
@@ -84,6 +84,10 @@ let package = Package(
.product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"),
.product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing"),
.product(name: "StructuredQueries", package: "swift-structured-queries"),
+ ],
+ resources: [
+ .copy("Resources/test-black.svg"),
+ .copy("Resources/test-red.svg")
]
),
],
diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift
index 3b6db09a..0281db2a 100644
--- a/Package@swift-6.0.swift
+++ b/Package@swift-6.0.swift
@@ -64,6 +64,10 @@ let package = Package(
.product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"),
.product(name: "SnapshotTestingCustomDump", package: "swift-snapshot-testing"),
.product(name: "StructuredQueries", package: "swift-structured-queries"),
+ ],
+ resources: [
+ .copy("Resources/test-black.svg"),
+ .copy("Resources/test-red.svg")
]
),
],
diff --git a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift
index c03123a1..37aeb4a9 100644
--- a/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift
+++ b/Sources/SQLiteData/CloudKit/CloudKit+StructuredQueries.swift
@@ -142,6 +142,10 @@
get { self["\(key)_hash"] as? Data }
set { self["\(key)_hash"] = newValue }
}
+ package subscript(data key: String) -> Data? {
+ get { self["\(key)_data"] as? Data }
+ set { self["\(key)_data"] = newValue }
+ }
}
@available(macOS 13, iOS 16, tvOS 16, watchOS 9, *)
@@ -182,6 +186,9 @@
encryptedValues[hash: key] != hash
else { return false }
+ if encryptedValues[data: key] != nil {
+ encryptedValues[data: key] = nil
+ }
self[key] = newValue
encryptedValues[hash: key] = hash
encryptedValues[at: key] = userModificationTime
@@ -198,6 +205,22 @@
guard encryptedValues[at: key] <= userModificationTime
else { return false }
+ if newValue.isSmall {
+ let newData = Data(newValue)
+ guard
+ encryptedValues[at: key] <= userModificationTime,
+ encryptedValues[data: key] != newData
+ else { return false }
+ if self[key] != nil {
+ self[key] = nil
+ encryptedValues[hash: key] = nil
+ }
+ encryptedValues[data: key] = newData
+ encryptedValues[at: key] = userModificationTime
+ self.userModificationTime = userModificationTime
+ return true
+ }
+
@Dependency(\.dataManager) var dataManager
let hash = newValue.sha256
let fileURL = dataManager.temporaryDirectory.appending(
@@ -229,6 +252,12 @@
}
if encryptedValues[key] != nil {
encryptedValues[key] = nil
+ encryptedValues[hash: key] = nil
+ encryptedValues[at: key] = userModificationTime
+ self.userModificationTime = userModificationTime
+ return true
+ } else if encryptedValues[data: key] != nil {
+ encryptedValues[data: key] = nil
encryptedValues[at: key] = userModificationTime
self.userModificationTime = userModificationTime
return true
@@ -299,7 +328,9 @@
didSet = setAsset(value, forKey: key, at: other.encryptedValues[at: key])
} else if let value = other.encryptedValues[key] as? any EquatableCKRecordValueProtocol {
didSet = setValue(value, forKey: key, at: other.encryptedValues[at: key])
- } else if other.encryptedValues[key] == nil {
+ } else if let data = other.encryptedValues[data: key] {
+ didSet = setValue(Array(data), forKey: key, at: other.encryptedValues[at: key])
+ } else if other.encryptedValues[key] == nil, other.encryptedValues[data: key] == nil {
didSet = removeValue(forKey: key, at: other.encryptedValues[at: key])
} else {
didSet = false
@@ -308,7 +339,16 @@
var isRowValueModified: Bool {
switch Value(queryOutput: row[keyPath: keyPath]).queryBinding {
case .blob(let value):
- return other.encryptedValues[hash: key] != value.sha256
+ if value.isSmall,
+ let serverData =
+ other.encryptedValues[key] as? Data ?? other.encryptedValues[data: key]
+ {
+ return serverData != Data(value)
+ } else if let otherHash = other.encryptedValues[hash: key] {
+ return otherHash != value.sha256
+ } else {
+ return true
+ }
case .bool(let value):
return other.encryptedValues[key] != value
case .double(let value):
@@ -364,6 +404,8 @@
return value.queryFragment
} else if let value = self as? Date {
return value.queryFragment
+ } else if let value = self as? [UInt8] {
+ return value.queryFragment
} else {
return "\(.invalid(Unbindable()))"
}
@@ -383,5 +425,10 @@
fileprivate var sha256: Data {
Data(SHA256.hash(data: self))
}
+
+ fileprivate var isSmall: Bool {
+ count <= 16_384
+ }
}
+
#endif
diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift
index 871c7999..017118c2 100644
--- a/Sources/SQLiteData/CloudKit/SyncEngine.swift
+++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift
@@ -2048,7 +2048,9 @@
}
return data?.queryFragment ?? "NULL"
} else {
- return record.encryptedValues[columnName]?.queryFragment ?? "NULL"
+ return record.encryptedValues[columnName]?.queryFragment
+ ?? record.encryptedValues[data: columnName]?.queryFragment
+ ?? "NULL"
}
}
.joined(separator: ", ")
@@ -2063,12 +2065,15 @@
if data == nil {
reportIssue("Asset data not found on disk")
}
- return
- "\(quote: columnName) = \(data?.queryFragment ?? #""excluded".\#(quote: columnName)"#)"
+ return "\(quote: columnName) = \(data?.queryFragment ?? #""excluded".\#(quote: columnName)"#)"
+ } else if let queryFragment = record.encryptedValues[columnName]?.queryFragment
+ ?? record.encryptedValues[data: columnName]?.queryFragment
+ {
+ return "\(quote: columnName) = \(queryFragment)"
} else {
return """
\(quote: columnName) = \
- \(record.encryptedValues[columnName]?.queryFragment ?? #""excluded".\#(quote: columnName)"#)
+ \(#""excluded".\#(quote: columnName)"#)
"""
}
}
@@ -2481,7 +2486,9 @@
return (try? asset.fileURL.map { try dataManager.load($0) })?
.queryFragment ?? "NULL"
} else {
- return record.encryptedValues[columnName]?.queryFragment ?? "NULL"
+ return record.encryptedValues[columnName]?.queryFragment
+ ?? record.encryptedValues[data: columnName]?.queryFragment
+ ?? "NULL"
}
}
.joined(separator: ", ")
diff --git a/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift b/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift
index 82e24399..c726cdb1 100644
--- a/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift
+++ b/Tests/SQLiteDataTests/CloudKitTests/AssetsTests.swift
@@ -14,17 +14,20 @@
final class AssetsTests: BaseCloudKitTests, @unchecked Sendable {
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test func basics() async throws {
+ let blackImageURL = Bundle.module.url(forResource: "test-black", withExtension: "svg")!
+ let blackCoverImage = try Data(contentsOf: blackImageURL)
+
try await userDatabase.userWrite { db in
try db.seed {
RemindersList(id: 1, title: "Personal")
- RemindersListAsset(remindersListID: 1, coverImage: Data("image".utf8))
+ RemindersListAsset(remindersListID: 1, coverImage: blackCoverImage)
}
}
try await syncEngine.processPendingRecordZoneChanges(scope: .private)
assertInlineSnapshot(of: container, as: .customDump) {
- """
+ #"""
MockCloudContainer(
privateCloudDatabase: MockCloudDatabase(
databaseScope: .private,
@@ -37,8 +40,94 @@
coverImage_hash: Data(32 bytes),
remindersListID: 1,
coverImage: CKAsset(
- fileURL: URL(file:///tmp/6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d),
- dataString: "image"
+ fileURL: URL(file:///tmp/4eb74bd60d41b48bd682896ff4ba846da5051a1e190159414b3ba177a9dbe482),
+ dataString: """
+
+
+ """
)
),
[1]: CKRecord(
@@ -56,23 +145,26 @@
storage: []
)
)
- """
+ """#
}
inMemoryDataManager.storage.withValue { storage in
let url = URL(
- string: "file:///tmp/6105d6cc76af400325e94d588ce511be5bfdbb73b437dc51eca43917d7a43e3d"
+ string: "file:///tmp/4eb74bd60d41b48bd682896ff4ba846da5051a1e190159414b3ba177a9dbe482"
)!
- #expect(storage[url] == Data("image".utf8))
+ #expect(storage[url] == blackCoverImage)
}
+ let redImageURL = Bundle.module.url(forResource: "test-red", withExtension: "svg")!
+ let redCoverImage = try Data(contentsOf: redImageURL)
+
try await withDependencies {
$0.currentTime.now += 1
} operation: {
try await userDatabase.userWrite { db in
try RemindersListAsset
.find(1)
- .update { $0.coverImage = #bind(Data("new-image".utf8)) }
+ .update { $0.coverImage = #bind(redCoverImage) }
.execute(db)
}
}
@@ -80,7 +172,7 @@
try await syncEngine.processPendingRecordZoneChanges(scope: .private)
assertInlineSnapshot(of: container, as: .customDump) {
- """
+ #"""
MockCloudContainer(
privateCloudDatabase: MockCloudDatabase(
databaseScope: .private,
@@ -93,8 +185,94 @@
coverImage_hash: Data(32 bytes),
remindersListID: 1,
coverImage: CKAsset(
- fileURL: URL(file:///tmp/97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf),
- dataString: "new-image"
+ fileURL: URL(file:///tmp/43aba58d3830c6821f433a10c9fd554e53c257ebfd9c451514ea2c27c774b79f),
+ dataString: """
+
+
+ """
)
),
[1]: CKRecord(
@@ -112,14 +290,14 @@
storage: []
)
)
- """
+ """#
}
inMemoryDataManager.storage.withValue { storage in
let url = URL(
- string: "file:///tmp/97e67a5645969953f1a4cfe2ea75649864ff99789189cdd3f6db03e59f8a8ebf"
+ string: "file:///tmp/43aba58d3830c6821f433a10c9fd554e53c257ebfd9c451514ea2c27c774b79f"
)!
- #expect(storage[url] == Data("new-image".utf8))
+ #expect(storage[url] == redCoverImage)
}
}
@@ -127,6 +305,8 @@
// => Stored in database as bytes
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test func receiveAsset() async throws {
+ let blackImageURL = Bundle.module.url(forResource: "test-black", withExtension: "svg")!
+ let blackCoverImage = try Data(contentsOf: blackImageURL)
let remindersListRecord = CKRecord(
recordType: RemindersList.tableName,
recordID: RemindersList.recordID(for: 1)
@@ -135,7 +315,7 @@
remindersListRecord.setValue("Personal", forKey: "title", at: now)
let fileURL = URL(fileURLWithPath: UUID().uuidString)
- try inMemoryDataManager.save(Data("image".utf8), to: fileURL)
+ try inMemoryDataManager.save(blackCoverImage, to: fileURL)
let remindersListAssetRecord = CKRecord(
recordType: RemindersListAsset.tableName,
recordID: RemindersListAsset.recordID(for: 1)
@@ -158,7 +338,7 @@
let remindersListAsset = try #require(
try RemindersListAsset.find(1).fetchOne(db)
)
- #expect(remindersListAsset.coverImage == Data("image".utf8))
+ #expect(remindersListAsset.coverImage == blackCoverImage)
}
}
@@ -166,19 +346,23 @@
// => Stored in database as bytes
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test func receiveUpdatedAsset() async throws {
+ let blackImageURL = Bundle.module.url(forResource: "test-black", withExtension: "svg")!
+ let blackCoverImage = try Data(contentsOf: blackImageURL)
try await userDatabase.userWrite { db in
try db.seed {
RemindersList(id: 1, title: "Personal")
- RemindersListAsset(remindersListID: 1, coverImage: Data("image".utf8))
+ RemindersListAsset(remindersListID: 1, coverImage: blackCoverImage)
}
}
try await syncEngine.processPendingRecordZoneChanges(scope: .private)
+ let redImageURL = Bundle.module.url(forResource: "test-red", withExtension: "svg")!
+ let redCoverImage = try Data(contentsOf: redImageURL)
try await withDependencies {
$0.currentTime.now += 1
} operation: {
let fileURL = URL(fileURLWithPath: UUID().uuidString)
- try inMemoryDataManager.save(Data("new-image".utf8), to: fileURL)
+ try inMemoryDataManager.save(redCoverImage, to: fileURL)
let remindersListAssetRecord = try syncEngine.private.database.record(
for: RemindersListAsset.recordID(for: 1)
)
@@ -198,7 +382,7 @@
let remindersListAsset = try #require(
try RemindersListAsset.find(1).fetchOne(db)
)
- #expect(remindersListAsset.coverImage == Data("new-image".utf8))
+ #expect(remindersListAsset.coverImage == redCoverImage)
}
}
@@ -208,6 +392,8 @@
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test func receiveAssetThenReceiveUpdate() async throws {
do {
+ let blackImageURL = Bundle.module.url(forResource: "test-black", withExtension: "svg")!
+ let blackCoverImage = try Data(contentsOf: blackImageURL)
let remindersListRecord = CKRecord(
recordType: RemindersList.tableName,
recordID: RemindersList.recordID(for: 1)
@@ -216,7 +402,7 @@
remindersListRecord.setValue("Personal", forKey: "title", at: now)
let fileURL = URL(fileURLWithPath: UUID().uuidString)
- try inMemoryDataManager.save(Data("image".utf8), to: fileURL)
+ try inMemoryDataManager.save(blackCoverImage, to: fileURL)
let remindersListAssetRecord = CKRecord(
recordType: RemindersListAsset.tableName,
recordID: RemindersListAsset.recordID(for: 1)
@@ -240,11 +426,13 @@
.notify()
}
+ let redImageURL = Bundle.module.url(forResource: "test-red", withExtension: "svg")!
+ let redCoverImage = try Data(contentsOf: redImageURL)
try await withDependencies {
$0.currentTime.now += 1
} operation: {
let fileURL = URL(fileURLWithPath: UUID().uuidString)
- try inMemoryDataManager.save(Data("new-image".utf8), to: fileURL)
+ try inMemoryDataManager.save(redCoverImage, to: fileURL)
let remindersListAssetRecord = try syncEngine.private.database.record(
for: RemindersListAsset.recordID(for: 1)
)
@@ -264,7 +452,7 @@
let remindersListAsset = try #require(
try RemindersListAsset.find(1).fetchOne(db)
)
- #expect(remindersListAsset.coverImage == Data("new-image".utf8))
+ #expect(remindersListAsset.coverImage == redCoverImage)
}
}
@@ -273,6 +461,8 @@
// => Both records (and the image data) should be synchronized
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
@Test func assetReceivedBeforeParentRecord() async throws {
+ let blackImageURL = Bundle.module.url(forResource: "test-black", withExtension: "svg")!
+ let blackCoverImage = try Data(contentsOf: blackImageURL)
let remindersListRecord = CKRecord(
recordType: RemindersList.tableName,
recordID: RemindersList.recordID(for: 1)
@@ -286,7 +476,7 @@
)
remindersListAssetRecord.setValue("1", forKey: "id", at: now)
remindersListAssetRecord.setValue(
- Array("image".utf8),
+ blackCoverImage,
forKey: "coverImage",
at: now
)
@@ -320,12 +510,12 @@
}
assertQuery(RemindersListAsset.all, database: userDatabase.database) {
"""
- ┌─────────────────────────────┐
- │ RemindersListAsset( │
- │ remindersListID: 1, │
- │ coverImage: Data(5 bytes) │
- │ ) │
- └─────────────────────────────┘
+ ┌──────────────────────────────────┐
+ │ RemindersListAsset( │
+ │ remindersListID: 1, │
+ │ coverImage: Data(16,811 bytes) │
+ │ ) │
+ └──────────────────────────────────┘
"""
}
diff --git a/Tests/SQLiteDataTests/CloudKitTests/DataTests.swift b/Tests/SQLiteDataTests/CloudKitTests/DataTests.swift
new file mode 100644
index 00000000..51b87751
--- /dev/null
+++ b/Tests/SQLiteDataTests/CloudKitTests/DataTests.swift
@@ -0,0 +1,309 @@
+#if canImport(CloudKit)
+ import CloudKit
+ import ConcurrencyExtras
+ import CustomDump
+ import InlineSnapshotTesting
+ import OrderedCollections
+ import SQLiteData
+ import SQLiteDataTestSupport
+ import SnapshotTestingCustomDump
+ import Testing
+
+ extension BaseCloudKitTests {
+ @MainActor
+ final class DataTests: BaseCloudKitTests, @unchecked Sendable {
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ @Test func basics() async throws {
+ try await userDatabase.userWrite { db in
+ try db.seed {
+ RemindersList(id: 1, title: "Personal")
+ RemindersListAsset(remindersListID: 1, coverImage: Data("image".utf8))
+ }
+ }
+
+ try await syncEngine.processPendingRecordZoneChanges(scope: .private)
+
+ assertInlineSnapshot(of: container, as: .customDump) {
+ """
+ MockCloudContainer(
+ privateCloudDatabase: MockCloudDatabase(
+ databaseScope: .private,
+ storage: [
+ [0]: CKRecord(
+ recordID: CKRecord.ID(1:remindersListAssets/zone/__defaultOwner__),
+ recordType: "remindersListAssets",
+ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)),
+ share: nil,
+ coverImage_data: Data(5 bytes),
+ remindersListID: 1
+ ),
+ [1]: CKRecord(
+ recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__),
+ recordType: "remindersLists",
+ parent: nil,
+ share: nil,
+ id: 1,
+ title: "Personal"
+ )
+ ]
+ ),
+ sharedCloudDatabase: MockCloudDatabase(
+ databaseScope: .shared,
+ storage: []
+ )
+ )
+ """
+ }
+
+ try await withDependencies {
+ $0.currentTime.now += 1
+ } operation: {
+ try await userDatabase.userWrite { db in
+ try RemindersListAsset
+ .find(1)
+ .update { $0.coverImage = #bind(Data("new-image".utf8)) }
+ .execute(db)
+ }
+ }
+
+ try await syncEngine.processPendingRecordZoneChanges(scope: .private)
+
+ assertInlineSnapshot(of: container, as: .customDump) {
+ """
+ MockCloudContainer(
+ privateCloudDatabase: MockCloudDatabase(
+ databaseScope: .private,
+ storage: [
+ [0]: CKRecord(
+ recordID: CKRecord.ID(1:remindersListAssets/zone/__defaultOwner__),
+ recordType: "remindersListAssets",
+ parent: CKReference(recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__)),
+ share: nil,
+ coverImage_data: Data(9 bytes),
+ remindersListID: 1
+ ),
+ [1]: CKRecord(
+ recordID: CKRecord.ID(1:remindersLists/zone/__defaultOwner__),
+ recordType: "remindersLists",
+ parent: nil,
+ share: nil,
+ id: 1,
+ title: "Personal"
+ )
+ ]
+ ),
+ sharedCloudDatabase: MockCloudDatabase(
+ databaseScope: .shared,
+ storage: []
+ )
+ )
+ """
+ }
+ }
+
+ // * Receive record with CKAsset from CloudKit
+ // => Stored in database as bytes
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ @Test func receiveData() async throws {
+ let remindersListRecord = CKRecord(
+ recordType: RemindersList.tableName,
+ recordID: RemindersList.recordID(for: 1)
+ )
+ remindersListRecord.setValue("1", forKey: "id", at: now)
+ remindersListRecord.setValue("Personal", forKey: "title", at: now)
+
+ let remindersListAssetRecord = CKRecord(
+ recordType: RemindersListAsset.tableName,
+ recordID: RemindersListAsset.recordID(for: 1)
+ )
+ remindersListAssetRecord.setValue("1", forKey: "id", at: now)
+ remindersListAssetRecord.setValue(Data("image".utf8), forKey: "coverImage", at: now)
+ remindersListAssetRecord.setValue("1", forKey: "remindersListID", at: now)
+ remindersListAssetRecord.parent = CKRecord.Reference(
+ record: remindersListRecord,
+ action: .none
+ )
+
+ try await syncEngine.modifyRecords(
+ scope: .private,
+ saving: [remindersListAssetRecord, remindersListRecord]
+ )
+ .notify()
+
+ try await userDatabase.read { db in
+ let remindersListAsset = try #require(
+ try RemindersListAsset.find(1).fetchOne(db)
+ )
+ #expect(remindersListAsset.coverImage == Data("image".utf8))
+ }
+ }
+
+ // * Receive record with Data from CloudKit when local asset exists
+ // => Stored in database as bytes
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ @Test func receiveUpdatedData() async throws {
+ try await userDatabase.userWrite { db in
+ try db.seed {
+ RemindersList(id: 1, title: "Personal")
+ RemindersListAsset(remindersListID: 1, coverImage: Data("image".utf8))
+ }
+ }
+ try await syncEngine.processPendingRecordZoneChanges(scope: .private)
+
+ try await withDependencies {
+ $0.currentTime.now += 1
+ } operation: {
+ let remindersListAssetRecord = try syncEngine.private.database.record(
+ for: RemindersListAsset.recordID(for: 1)
+ )
+ remindersListAssetRecord.setValue(
+ Data("new-image".utf8),
+ forKey: "coverImage",
+ at: now
+ )
+ try await syncEngine.modifyRecords(
+ scope: .private,
+ saving: [remindersListAssetRecord]
+ )
+ .notify()
+ }
+
+ try await userDatabase.read { db in
+ let remindersListAsset = try #require(
+ try RemindersListAsset.find(1).fetchOne(db)
+ )
+ #expect(remindersListAsset.coverImage == Data("new-image".utf8))
+ }
+ }
+
+ // * Receive record with CKAsset from CloudKit when local asset does not exist
+ // * Receive updated asset from CloudKit
+ // => Local database has freshest asset
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ @Test func receiveAssetThenReceiveUpdate() async throws {
+ do {
+ let remindersListRecord = CKRecord(
+ recordType: RemindersList.tableName,
+ recordID: RemindersList.recordID(for: 1)
+ )
+ remindersListRecord.setValue("1", forKey: "id", at: now)
+ remindersListRecord.setValue("Personal", forKey: "title", at: now)
+
+ let fileURL = URL(fileURLWithPath: UUID().uuidString)
+ try inMemoryDataManager.save(Data("image".utf8), to: fileURL)
+ let remindersListAssetRecord = CKRecord(
+ recordType: RemindersListAsset.tableName,
+ recordID: RemindersListAsset.recordID(for: 1)
+ )
+ remindersListAssetRecord.setValue("1", forKey: "id", at: now)
+ remindersListAssetRecord.setAsset(
+ CKAsset(fileURL: fileURL),
+ forKey: "coverImage",
+ at: now
+ )
+ remindersListAssetRecord.setValue("1", forKey: "remindersListID", at: now)
+ remindersListAssetRecord.parent = CKRecord.Reference(
+ record: remindersListRecord,
+ action: .none
+ )
+
+ try await syncEngine.modifyRecords(
+ scope: .private,
+ saving: [remindersListAssetRecord, remindersListRecord]
+ )
+ .notify()
+ }
+
+ try await withDependencies {
+ $0.currentTime.now += 1
+ } operation: {
+ let fileURL = URL(fileURLWithPath: UUID().uuidString)
+ try inMemoryDataManager.save(Data("new-image".utf8), to: fileURL)
+ let remindersListAssetRecord = try syncEngine.private.database.record(
+ for: RemindersListAsset.recordID(for: 1)
+ )
+ remindersListAssetRecord.setAsset(
+ CKAsset(fileURL: fileURL),
+ forKey: "coverImage",
+ at: now
+ )
+ try await syncEngine.modifyRecords(
+ scope: .private,
+ saving: [remindersListAssetRecord]
+ )
+ .notify()
+ }
+
+ try await userDatabase.read { db in
+ let remindersListAsset = try #require(
+ try RemindersListAsset.find(1).fetchOne(db)
+ )
+ #expect(remindersListAsset.coverImage == Data("new-image".utf8))
+ }
+ }
+
+ // * Client receives RemindersListAsset with image data
+ // * A moment later client receives the parent RemindersList
+ // => Both records (and the image data) should be synchronized
+ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
+ @Test func assetReceivedBeforeParentRecord() async throws {
+ let remindersListRecord = CKRecord(
+ recordType: RemindersList.tableName,
+ recordID: RemindersList.recordID(for: 1)
+ )
+ remindersListRecord.setValue("1", forKey: "id", at: now)
+ remindersListRecord.setValue("Personal", forKey: "title", at: now)
+
+ let remindersListAssetRecord = CKRecord(
+ recordType: RemindersListAsset.tableName,
+ recordID: RemindersListAsset.recordID(for: 1)
+ )
+ remindersListAssetRecord.setValue("1", forKey: "id", at: now)
+ remindersListAssetRecord.setValue(
+ Array("image".utf8),
+ forKey: "coverImage",
+ at: now
+ )
+ remindersListAssetRecord.setValue(
+ "1",
+ forKey: "remindersListID",
+ at: now
+ )
+ remindersListAssetRecord.parent = CKRecord.Reference(
+ record: remindersListRecord,
+ action: .none
+ )
+
+ let remindersListModification = try syncEngine.modifyRecords(
+ scope: .private,
+ saving: [remindersListRecord]
+ )
+ try await syncEngine.modifyRecords(scope: .private, saving: [remindersListAssetRecord])
+ .notify()
+ await remindersListModification.notify()
+
+ assertQuery(RemindersList.all, database: userDatabase.database) {
+ """
+ ┌─────────────────────┐
+ │ RemindersList( │
+ │ id: 1, │
+ │ title: "Personal" │
+ │ ) │
+ └─────────────────────┘
+ """
+ }
+ assertQuery(RemindersListAsset.all, database: userDatabase.database) {
+ """
+ ┌─────────────────────────────┐
+ │ RemindersListAsset( │
+ │ remindersListID: 1, │
+ │ coverImage: Data(5 bytes) │
+ │ ) │
+ └─────────────────────────────┘
+ """
+ }
+
+ }
+ }
+ }
+#endif
diff --git a/Tests/SQLiteDataTests/Resources/test-black.svg b/Tests/SQLiteDataTests/Resources/test-black.svg
new file mode 100644
index 00000000..1f42b9bf
--- /dev/null
+++ b/Tests/SQLiteDataTests/Resources/test-black.svg
@@ -0,0 +1,85 @@
+
+
\ No newline at end of file
diff --git a/Tests/SQLiteDataTests/Resources/test-red.svg b/Tests/SQLiteDataTests/Resources/test-red.svg
new file mode 100644
index 00000000..dc16c928
--- /dev/null
+++ b/Tests/SQLiteDataTests/Resources/test-red.svg
@@ -0,0 +1,85 @@
+
+
\ No newline at end of file