From 455fbce15a5339f349d0ed1c4df28ae54dcd8335 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 27 Oct 2026 09:37:40 -0500 Subject: [PATCH 1/7] Support rate limiting in mock database. --- Examples/Reminders/Schema.swift | 4 +- Package.resolved | 20 +- .../CloudKit/Internal/MockCloudDatabase.swift | 48 +++- .../CloudKit/Internal/MockSyncEngine.swift | 24 +- .../CloudKit/Internal/Triggers.swift | 8 +- Sources/SQLiteData/CloudKit/SyncEngine.swift | 2 +- .../MockCloudDatabaseRateLimitTests.swift | 228 ++++++++++++++++++ .../CloudKitTests/SyncEngineTests.swift | 28 +-- .../CloudKitTests/UserlandTests.swift | 52 ++-- .../SQLiteDataTests/CustomFunctionTests.swift | 4 +- .../DatabaseFunctionTests.swift | 2 +- .../Internal/CloudKitTestHelpers.swift | 32 ++- 12 files changed, 357 insertions(+), 95 deletions(-) create mode 100644 Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index e63f667a9..1cf4747a0 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -288,7 +288,7 @@ func appDatabase() throws -> any DatabaseWriter { try RemindersList.createTemporaryTrigger( after: .delete { _ in - Values($createDefaultRemindersList()) + Select($createDefaultRemindersList()) } when: { _ in !RemindersList.exists() } @@ -365,7 +365,7 @@ func appDatabase() throws -> any DatabaseWriter { after: .update { $0.status } forEachRow: { _, _ in - Values($handleReminderStatusUpdate()) + Select($handleReminderStatusUpdate()) } when: { _, new in new.status.eq(#bind(.completing)) } diff --git a/Package.resolved b/Package.resolved index 52f071db8..c5ba82db2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3e318606e30fd7507b3905998ae154d62963a3667c482b7e92aac0fbf624db2c", + "originHash" : "f2716017e30241f9c92ea950833ba4445053ca0764a53804f0294194b91564c3", "pins" : [ { "identity" : "combine-schedulers", @@ -19,15 +19,6 @@ "version" : "7.11.1" } }, - { - "identity" : "swift-case-paths", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-case-paths", - "state" : { - "revision" : "cb4cba6a8a46a86db0d37dbe09935ead6b24c91a", - "version" : "1.10.0" - } - }, { "identity" : "swift-clocks", "kind" : "remoteSourceControl", @@ -109,15 +100,6 @@ "version" : "2.1.0" } }, - { - "identity" : "swift-navigation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-navigation", - "state" : { - "revision" : "7e95e5e9ff0a64f6ad8bca59083f8d6ba381b6ff", - "version" : "2.11.2" - } - }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index b0149fd23..7c758a7db 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -6,16 +6,20 @@ @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) package final class MockCloudDatabase: CloudDatabase { - package let state = LockIsolated(State()) + package let state: LockIsolated package let databaseScope: CKDatabase.Scope let _container = IsolatedWeakVar() let dataManager = Dependency(\.dataManager) + let clock: AnyClock package struct State { private var lastRecordChangeTag = 0 package var storage: [CKRecordZone.ID: Zone] = [:] var assets: [AssetID: Data] = [:] var deletedRecords: [(CKRecord.ID, CKRecord.RecordType)] = [] + package var rateLimit: Int + package var rateLimitDuration: Duration + fileprivate var requestTimestamps: [AnyClock.Instant] = [] mutating func nextRecordChangeTag() -> Int { lastRecordChangeTag += 1 return lastRecordChangeTag @@ -32,8 +36,15 @@ package var records: [CKRecord.ID: CKRecord] = [:] } - package init(databaseScope: CKDatabase.Scope) { + package init( + databaseScope: CKDatabase.Scope, + rateLimit: Int = .max, + rateLimitDuration: Duration = .seconds(1) + ) { self.databaseScope = databaseScope + @Dependency(\.continuousClock) var clock + self.clock = AnyClock(clock) + self.state = LockIsolated(State(rateLimit: rateLimit, rateLimitDuration: rateLimitDuration)) } package func set(container: MockCloudContainer) { @@ -48,6 +59,7 @@ let accountStatus = container.accountStatus() guard accountStatus == .available else { throw ckError(forAccountStatus: accountStatus) } + try updateRateLimit() let record = try state.withValue { state in guard let zone = state.storage[recordID.zoneID] else { throw CKError(.zoneNotFound) } @@ -78,13 +90,16 @@ let accountStatus = container.accountStatus() guard accountStatus == .available else { throw ckError(forAccountStatus: accountStatus) } + try updateRateLimit() guard ids.count < 200 else { throw CKError(.limitExceeded) } var results: [CKRecord.ID: Result] = [:] - for id in ids { - results[id] = Result { try record(for: id) } + $_isRateLimitingDisabled.withValue(true) { + for id in ids { + results[id] = Result { try record(for: id) } + } } return results } @@ -101,6 +116,7 @@ let accountStatus = container.accountStatus() guard accountStatus == .available else { throw ckError(forAccountStatus: accountStatus) } + try updateRateLimit() guard (recordsToSave.count + recordIDsToDelete.count) < 200 else { @@ -367,6 +383,7 @@ let accountStatus = container.accountStatus() guard accountStatus == .available else { throw ckError(forAccountStatus: accountStatus) } + try updateRateLimit() return state.withValue { state in var saveResults: [CKRecordZone.ID: Result] = [:] @@ -392,6 +409,26 @@ } } + private func updateRateLimit() throws { + guard !_isRateLimitingDisabled else { return } + try state.withValue { state in + guard state.rateLimit != .max + else { return } + let now = clock.now + state.requestTimestamps.removeAll { $0.duration(to: now) >= state.rateLimitDuration } + let isRateLimited = state.requestTimestamps.count >= state.rateLimit + state.requestTimestamps.append(now) + guard !isRateLimited + else { + let (seconds, _) = state.rateLimitDuration.components + throw CKError( + .requestRateLimited, + userInfo: [CKErrorRetryAfterKey: Double(seconds)] + ) + } + } + } + package nonisolated static func == (lhs: MockCloudDatabase, rhs: MockCloudDatabase) -> Bool { lhs === rhs } @@ -401,6 +438,8 @@ } } + @TaskLocal package var _isRateLimitingDisabled = false + @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) private func ckError(forAccountStatus accountStatus: CKAccountStatus) -> CKError { switch accountStatus { @@ -414,4 +453,5 @@ fatalError() } } + #endif diff --git a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift index e250592ad..e3f1f804d 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockSyncEngine.swift @@ -267,12 +267,14 @@ return SendRecordsCallback {} } - let (saveResults, deleteResults) = try syncEngine.database.modifyRecords( - saving: batch.recordsToSave, - deleting: batch.recordIDsToDelete, - savePolicy: .ifServerRecordUnchanged, - atomically: batch.atomicByZone - ) + let (saveResults, deleteResults) = try $_isRateLimitingDisabled.withValue(true) { + try syncEngine.database.modifyRecords( + saving: batch.recordsToSave, + deleting: batch.recordIDsToDelete, + savePolicy: .ifServerRecordUnchanged, + atomically: batch.atomicByZone + ) + } var savedRecords: [CKRecord] = [] var failedRecordSaves: [(record: CKRecord, error: CKError)] = [] @@ -395,10 +397,12 @@ ( saveResults: [CKRecordZone.ID: Result], deleteResults: [CKRecordZone.ID: Result] - ) = try syncEngine.database.modifyRecordZones( - saving: zonesToSave, - deleting: zoneIDsToDelete - ) + ) = try $_isRateLimitingDisabled.withValue(true) { + try syncEngine.database.modifyRecordZones( + saving: zonesToSave, + deleting: zoneIDsToDelete + ) + } var savedZones: [CKRecordZone] = [] var failedZoneSaves: [(zone: CKRecordZone, error: CKError)] = [] var deletedZoneIDs: [CKRecordZone.ID] = [] diff --git a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift index eebcafc2f..37bd835e3 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift @@ -198,7 +198,7 @@ $0.parentRecordType ) } select: { - Values( + Select( #sql("\(new.primaryKey)"), T.tableName, zoneName ?? defaultZoneName, @@ -254,7 +254,7 @@ ifNotExists: true, after: .insert { new in validate(recordName: new.recordName) - Values( + Select( syncEngine.$didUpdate( recordName: new.recordName, zoneName: new.zoneName, @@ -314,7 +314,7 @@ } validate(recordName: new.recordName) - Values( + Select( syncEngine.$didUpdate( recordName: new.recordName, zoneName: new.zoneName, @@ -337,7 +337,7 @@ "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_sqlitedata_icloud_metadata", ifNotExists: true, after: .update(of: \._isDeleted) { _, new in - Values( + Select( syncEngine.$didDelete( recordName: new.recordName, record: new.lastKnownServerRecord diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 036ff3fa8..21a380aed 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1530,7 +1530,7 @@ let unsyncedRecords = await withErrorReporting(.sqliteDataCloudKitFailure) { - var unsyncedRecordIDs = try await userDatabase.write { db in + var unsyncedRecordIDs = try await metadatabase.read { db in Set( try UnsyncedRecordID.all .fetchAll(db) diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift new file mode 100644 index 000000000..ac0e98bf3 --- /dev/null +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift @@ -0,0 +1,228 @@ +#if canImport(CloudKit) + import Clocks + import CloudKit + import ConcurrencyExtras + import Dependencies + import SQLiteData + import Testing + + extension BaseCloudKitTests { + @MainActor + final class MockCloudDatabaseRateLimitTests: BaseCloudKitTests, @unchecked Sendable { + let recordID = CKRecord.ID( + recordName: "A", + zoneID: CKRecordZone.ID(zoneName: "zone") + ) + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func unlimitedByDefault() async throws { + let database = try createDatabase() + for _ in 1...1_000 { + _ = try database.record(for: recordID) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func rateLimitedOnceLimitReached() async throws { + let database = try createDatabase(rateLimit: 3, rateLimitDuration: .seconds(10)) + + for _ in 1...3 { + _ = try database.record(for: recordID) + } + let error = #expect(throws: CKError.self) { + try database.record(for: self.recordID) + } + #expect(error?.code == .requestRateLimited) + #expect(error?.retryAfterSeconds == 10) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func zeroLimit() async throws { + let database = try createDatabase(rateLimit: 0, rateLimitDuration: .seconds(10)) + + let error = #expect(throws: CKError.self) { + try database.record(for: self.recordID) + } + #expect(error?.code == .requestRateLimited) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func rateLimitResetsAfterDuration() async throws { + let database = try createDatabase(rateLimit: 2, rateLimitDuration: .seconds(10)) + + _ = try database.record(for: recordID) + _ = try database.record(for: recordID) + #expect(throws: CKError.self) { try database.record(for: self.recordID) } + + await testClock.advance(by: .seconds(10)) + _ = try database.record(for: recordID) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func slidingWindow() async throws { + let database = try createDatabase(rateLimit: 2, rateLimitDuration: .seconds(10)) + + _ = try database.record(for: recordID) + await testClock.advance(by: .seconds(5)) + _ = try database.record(for: recordID) + + await testClock.advance(by: .seconds(5)) + _ = try database.record(for: recordID) + #expect(throws: CKError.self) { try database.record(for: self.recordID) } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func rejectedRequestsCountAgainstRateLimit() async throws { + let database = try createDatabase(rateLimit: 1, rateLimitDuration: .seconds(10)) + + _ = try database.record(for: recordID) + await testClock.advance(by: .seconds(5)) + #expect(throws: CKError.self) { try database.record(for: self.recordID) } + + await testClock.advance(by: .seconds(5)) + #expect(throws: CKError.self) { try database.record(for: self.recordID) } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func waitingRetryAfterSecondsSucceeds() async throws { + let database = try createDatabase(rateLimit: 3, rateLimitDuration: .seconds(10)) + + for _ in 1...3 { + _ = try database.record(for: recordID) + } + for _ in 1...5 { + await testClock.advance(by: .seconds(1)) + #expect(throws: CKError.self) { try database.record(for: self.recordID) } + } + let error = #expect(throws: CKError.self) { + try database.record(for: self.recordID) + } + let retryAfterSeconds = try #require(error?.retryAfterSeconds) + + await testClock.advance(by: .seconds(retryAfterSeconds)) + _ = try database.record(for: recordID) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func allRequestsShareRateLimit() async throws { + let database = try createDatabase(rateLimit: 4, rateLimitDuration: .seconds(10)) + + _ = try database.record(for: recordID) + _ = try database.records(for: [recordID], desiredKeys: nil) + _ = try database.modifyRecords(saving: [], deleting: []) + _ = try database.modifyRecordZones(saving: [], deleting: []) + + let errors = [ + #expect(throws: CKError.self) { + try database.record(for: self.recordID) + }, + #expect(throws: CKError.self) { + try database.records(for: [self.recordID], desiredKeys: nil) + }, + #expect(throws: CKError.self) { + try database.modifyRecords(saving: [], deleting: []) + }, + #expect(throws: CKError.self) { + try database.modifyRecordZones(saving: [], deleting: []) + }, + ] + #expect(errors.map(\.?.code) == Array(repeating: .requestRateLimited, count: 4)) + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func batchFetchCountsAsSingleRequest() async throws { + let database = try createDatabase(rateLimit: 2, rateLimitDuration: .seconds(10)) + + let recordIDs = Array(repeating: recordID, count: 10) + let results = try database.records(for: recordIDs, desiredKeys: nil) + #expect(results.values.allSatisfy { (try? $0.get()) != nil }) + _ = try database.records(for: recordIDs, desiredKeys: nil) + #expect(throws: CKError.self) { + try database.records(for: recordIDs, desiredKeys: nil) + } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func limitExceededCountsAgainstRateLimit() async throws { + let database = try createDatabase(rateLimit: 1, rateLimitDuration: .seconds(10)) + + let error = #expect(throws: CKError.self) { + try database.records( + for: Array(repeating: self.recordID, count: 200), + desiredKeys: nil + ) + } + #expect(error?.code == .limitExceeded) + #expect(throws: CKError.self) { try database.record(for: self.recordID) } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func rateLimitingDisabled() async throws { + let database = try createDatabase(rateLimit: 1, rateLimitDuration: .seconds(10)) + + try $_isRateLimitingDisabled.withValue(true) { + for _ in 1...10 { + _ = try database.record(for: recordID) + } + } + _ = try database.record(for: recordID) + #expect(throws: CKError.self) { try database.record(for: self.recordID) } + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func syncEngineIsExempt() async throws { + syncEngine.private.database.state.withValue { + $0.rateLimit = 0 + $0.rateLimitDuration = .seconds(10) + } + + try await userDatabase.userWrite { db in + try RemindersList.insert { RemindersList(id: 1, title: "Personal") }.execute(db) + } + try await syncEngine.processPendingRecordZoneChanges(scope: .private) + + let remoteRecord = CKRecord( + recordType: RemindersList.tableName, + recordID: RemindersList.recordID(for: 2) + ) + remoteRecord.setValue(2, forKey: "id", at: now) + remoteRecord.setValue("Business", forKey: "title", at: now) + try await syncEngine.modifyRecords(scope: .private, saving: [remoteRecord]).notify() + + let storedRecordIDs = syncEngine.private.database.state.withValue { + Set($0.storage.values.flatMap(\.records.keys)) + } + #expect(storedRecordIDs.contains(RemindersList.recordID(for: 1))) + try await userDatabase.read { db in + try #expect(RemindersList.fetchCount(db) == 2) + } + #expect(throws: CKError.self) { + try self.syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) + } + } + + private func createDatabase( + rateLimit: Int = .max, + rateLimitDuration: Duration = .seconds(Int64.max) + ) throws -> MockCloudDatabase { + let database = MockCloudDatabase( + databaseScope: .private, + rateLimit: rateLimit, + rateLimitDuration: rateLimitDuration + ) + database.set(container: container) + try $_isRateLimitingDisabled.withValue(true) { + _ = try database.modifyRecordZones( + saving: [CKRecordZone(zoneID: recordID.zoneID)], + deleting: [] + ) + _ = try database.modifyRecords( + saving: [CKRecord(recordType: "A", recordID: recordID)], + deleting: [] + ) + } + return database + } + } + } +#endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift index 4d6a16bac..e17c13620 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift @@ -124,7 +124,7 @@ withKnownIssue { _ = Reminder.createTemporaryTrigger( after: .insert { new in - Values(SyncEngine.isSynchronizing) + Select(SyncEngine.isSynchronizing) } ) } matching: { issue in @@ -137,26 +137,26 @@ } _ = Reminder.createTemporaryTrigger( after: .insert { new in - Values(SyncEngine.$isSynchronizing) + Select(SyncEngine.$isSynchronizing) } ) } } - } - private func databaseWithForeignKeys() throws -> any DatabaseWriter { - try DatabaseQueue() - } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func testSyncEngine() throws { + @Dependency(\.defaultSyncEngine) var syncEngine + _ = syncEngine + } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func testSyncEngine() throws { - @Dependency(\.defaultSyncEngine) var syncEngine - _ = syncEngine + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test(.dependency(\.context, .preview)) func previewSyncEngine() throws { + @Dependency(\.defaultSyncEngine) var syncEngine + _ = syncEngine + } } - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test(.dependency(\.context, .preview)) func previewSyncEngine() throws { - @Dependency(\.defaultSyncEngine) var syncEngine - _ = syncEngine + private func databaseWithForeignKeys() throws -> any DatabaseWriter { + try DatabaseQueue() } #endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift index 4f755037a..b2d8eedde 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/UserlandTests.swift @@ -3,34 +3,36 @@ import SQLiteData import Testing - @Suite struct UserlandTests { - @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func basics() async throws { - let database = try SQLiteDataTests.database( - containerIdentifier: "tests", - attachMetadatabase: false - ) - let syncEngine = try SyncEngine( - for: database, - tables: ModelA.self, - ModelB.self, - ModelC.self, - containerIdentifier: "tests" - ) + extension BaseCloudKitTests { + @Suite struct UserlandTests { + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + @Test func basics() async throws { + let database = try SQLiteDataTests.database( + containerIdentifier: "tests", + attachMetadatabase: false + ) + let syncEngine = try SyncEngine( + for: database, + tables: ModelA.self, + ModelB.self, + ModelC.self, + containerIdentifier: "tests" + ) - try await withDependencies { - $0.defaultDatabase = database - $0.defaultSyncEngine = syncEngine - $0.currentTime.now = 1 - } operation: { - @FetchAll var modelAs: [ModelA] = [] - try await database.write { db in - try db.seed { - ModelA.Draft(id: 1) + try await withDependencies { + $0.defaultDatabase = database + $0.defaultSyncEngine = syncEngine + $0.currentTime.now = 1 + } operation: { + @FetchAll var modelAs: [ModelA] = [] + try await database.write { db in + try db.seed { + ModelA.Draft(id: 1) + } } + try await $modelAs.load() + #expect(modelAs == [ModelA(id: 1, isEven: true)]) } - try await $modelAs.load() - #expect(modelAs == [ModelA(id: 1, isEven: true)]) } } } diff --git a/Tests/SQLiteDataTests/CustomFunctionTests.swift b/Tests/SQLiteDataTests/CustomFunctionTests.swift index 3c9436e88..897d1a53d 100644 --- a/Tests/SQLiteDataTests/CustomFunctionTests.swift +++ b/Tests/SQLiteDataTests/CustomFunctionTests.swift @@ -15,7 +15,7 @@ import Testing } let database = try DatabaseQueue(configuration: configuration) let date = try database.read { db in - try Values($customDate()) + try Select($customDate()) .fetchOne(db) } #expect(date?.timeIntervalSinceReferenceDate == 0) @@ -25,7 +25,7 @@ import Testing } #expect(throws: (any Error).self) { try database.read { db in - _ = try Values($customDate()).fetchOne(db) + _ = try Select($customDate()).fetchOne(db) } } } diff --git a/Tests/SQLiteDataTests/DatabaseFunctionTests.swift b/Tests/SQLiteDataTests/DatabaseFunctionTests.swift index f4555e0b8..9f4629f51 100644 --- a/Tests/SQLiteDataTests/DatabaseFunctionTests.swift +++ b/Tests/SQLiteDataTests/DatabaseFunctionTests.swift @@ -15,7 +15,7 @@ struct DatabaseFunctionTests { db.add(function: $exclaim) } let database = try DatabaseQueue(configuration: configuration) - assertQuery(Values($exclaim("Blob")), database: database) { + assertQuery(Select($exclaim("Blob")), database: database) { """ ┌─────────┐ │ "Blob!" │ diff --git a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift index 9228bfe2c..ff17bc80e 100644 --- a/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift +++ b/Tests/SQLiteDataTests/Internal/CloudKitTestHelpers.swift @@ -34,10 +34,12 @@ extension SyncEngine { ) throws -> ModifyRecordsCallback { let syncEngine = syncEngine(for: scope) - let (saveResults, deleteResults) = try syncEngine.database.modifyRecordZones( - saving: recordZonesToSave, - deleting: recordZoneIDsToDelete - ) + let (saveResults, deleteResults) = try $_isRateLimitingDisabled.withValue(true) { + try syncEngine.database.modifyRecordZones( + saving: recordZonesToSave, + deleting: recordZoneIDsToDelete + ) + } return ModifyRecordsCallback { await syncEngine.parentSyncEngine @@ -72,11 +74,13 @@ extension SyncEngine { ) .compactMapValues(\.first) - let (saveResults, deleteResults) = try syncEngine.database.modifyRecords( - saving: recordsToSave, - deleting: recordIDsToDelete, - atomically: atomically - ) + let (saveResults, deleteResults) = try $_isRateLimitingDisabled.withValue(true) { + try syncEngine.database.modifyRecords( + saving: recordsToSave, + deleting: recordIDsToDelete, + atomically: atomically + ) + } return ModifyRecordsCallback { let savedRecordIDs = saveResults.compactMap { recordID, result in @@ -86,10 +90,12 @@ extension SyncEngine { (try? result.get()) != nil ? recordID : nil } let freshRecords = - (try? syncEngine.database.records( - for: savedRecordIDs + deletedRecordIDs, - desiredKeys: nil - )) ?? [:] + (try? $_isRateLimitingDisabled.withValue(true) { + try syncEngine.database.records( + for: savedRecordIDs + deletedRecordIDs, + desiredKeys: nil + ) + }) ?? [:] await syncEngine.parentSyncEngine.handleEvent( .fetchedRecordZoneChanges( modifications: savedRecordIDs.compactMap { recordID in From 4a89fac9d62d2978a71bbf6bfc579cb74614eea6 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 27 Oct 2026 14:00:05 -0500 Subject: [PATCH 2/7] Use a task local for rate limit options. --- Sources/TestLocals/TestLocals.swift | 2 + .../MockCloudDatabaseRateLimitTests.swift | 105 +++++++----------- .../Internal/BaseCloudKitTests.swift | 12 +- 3 files changed, 54 insertions(+), 65 deletions(-) diff --git a/Sources/TestLocals/TestLocals.swift b/Sources/TestLocals/TestLocals.swift index 2ff92890c..341c09c46 100644 --- a/Sources/TestLocals/TestLocals.swift +++ b/Sources/TestLocals/TestLocals.swift @@ -7,6 +7,8 @@ @TaskLocal package var startImmediately = true @TaskLocal package var attachMetadatabase = false @TaskLocal package var accountStatus = CKAccountStatus.available + @TaskLocal package var rateLimit = Int.max + @TaskLocal package var rateLimitDuration = Duration.seconds(1) @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @TaskLocal package var syncEngineDelegate: (any SyncEngineDelegate)? = nil #endif diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift index ac0e98bf3..67025ccc1 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift @@ -5,27 +5,42 @@ import Dependencies import SQLiteData import Testing + import TestLocals extension BaseCloudKitTests { @MainActor + @Suite(.taskLocal($rateLimitDuration, .seconds(10))) final class MockCloudDatabaseRateLimitTests: BaseCloudKitTests, @unchecked Sendable { let recordID = CKRecord.ID( recordName: "A", - zoneID: CKRecordZone.ID(zoneName: "zone") + zoneID: SyncEngine.defaultTestZone.zoneID ) + var database: MockCloudDatabase { + syncEngine.private.database + } + + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) + override init() async throws { + try await super.init() + try $_isRateLimitingDisabled.withValue(true) { + _ = try database.modifyRecords( + saving: [CKRecord(recordType: "A", recordID: recordID)], + deleting: [] + ) + } + } + @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) @Test func unlimitedByDefault() async throws { - let database = try createDatabase() for _ in 1...1_000 { _ = try database.record(for: recordID) } } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func rateLimitedOnceLimitReached() async throws { - let database = try createDatabase(rateLimit: 3, rateLimitDuration: .seconds(10)) - + @Test(.taskLocal($rateLimit, 3)) + func rateLimitedOnceLimitReached() async throws { for _ in 1...3 { _ = try database.record(for: recordID) } @@ -37,9 +52,8 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func zeroLimit() async throws { - let database = try createDatabase(rateLimit: 0, rateLimitDuration: .seconds(10)) - + @Test(.taskLocal($rateLimit, 0)) + func zeroLimit() async throws { let error = #expect(throws: CKError.self) { try database.record(for: self.recordID) } @@ -47,9 +61,8 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func rateLimitResetsAfterDuration() async throws { - let database = try createDatabase(rateLimit: 2, rateLimitDuration: .seconds(10)) - + @Test(.taskLocal($rateLimit, 2)) + func rateLimitResetsAfterDuration() async throws { _ = try database.record(for: recordID) _ = try database.record(for: recordID) #expect(throws: CKError.self) { try database.record(for: self.recordID) } @@ -59,9 +72,8 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func slidingWindow() async throws { - let database = try createDatabase(rateLimit: 2, rateLimitDuration: .seconds(10)) - + @Test(.taskLocal($rateLimit, 2)) + func slidingWindow() async throws { _ = try database.record(for: recordID) await testClock.advance(by: .seconds(5)) _ = try database.record(for: recordID) @@ -72,9 +84,8 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func rejectedRequestsCountAgainstRateLimit() async throws { - let database = try createDatabase(rateLimit: 1, rateLimitDuration: .seconds(10)) - + @Test(.taskLocal($rateLimit, 1)) + func rejectedRequestsCountAgainstRateLimit() async throws { _ = try database.record(for: recordID) await testClock.advance(by: .seconds(5)) #expect(throws: CKError.self) { try database.record(for: self.recordID) } @@ -84,9 +95,8 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func waitingRetryAfterSecondsSucceeds() async throws { - let database = try createDatabase(rateLimit: 3, rateLimitDuration: .seconds(10)) - + @Test(.taskLocal($rateLimit, 3)) + func waitingRetryAfterSecondsSucceeds() async throws { for _ in 1...3 { _ = try database.record(for: recordID) } @@ -104,9 +114,8 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func allRequestsShareRateLimit() async throws { - let database = try createDatabase(rateLimit: 4, rateLimitDuration: .seconds(10)) - + @Test(.taskLocal($rateLimit, 4)) + func allRequestsShareRateLimit() async throws { _ = try database.record(for: recordID) _ = try database.records(for: [recordID], desiredKeys: nil) _ = try database.modifyRecords(saving: [], deleting: []) @@ -130,9 +139,8 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func batchFetchCountsAsSingleRequest() async throws { - let database = try createDatabase(rateLimit: 2, rateLimitDuration: .seconds(10)) - + @Test(.taskLocal($rateLimit, 2)) + func batchFetchCountsAsSingleRequest() async throws { let recordIDs = Array(repeating: recordID, count: 10) let results = try database.records(for: recordIDs, desiredKeys: nil) #expect(results.values.allSatisfy { (try? $0.get()) != nil }) @@ -143,9 +151,8 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func limitExceededCountsAgainstRateLimit() async throws { - let database = try createDatabase(rateLimit: 1, rateLimitDuration: .seconds(10)) - + @Test(.taskLocal($rateLimit, 1)) + func limitExceededCountsAgainstRateLimit() async throws { let error = #expect(throws: CKError.self) { try database.records( for: Array(repeating: self.recordID, count: 200), @@ -157,9 +164,8 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func rateLimitingDisabled() async throws { - let database = try createDatabase(rateLimit: 1, rateLimitDuration: .seconds(10)) - + @Test(.taskLocal($rateLimit, 1)) + func rateLimitingDisabled() async throws { try $_isRateLimitingDisabled.withValue(true) { for _ in 1...10 { _ = try database.record(for: recordID) @@ -170,12 +176,8 @@ } @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) - @Test func syncEngineIsExempt() async throws { - syncEngine.private.database.state.withValue { - $0.rateLimit = 0 - $0.rateLimitDuration = .seconds(10) - } - + @Test(.taskLocal($rateLimit, 0)) + func syncEngineIsExempt() async throws { try await userDatabase.userWrite { db in try RemindersList.insert { RemindersList(id: 1, title: "Personal") }.execute(db) } @@ -189,7 +191,7 @@ remoteRecord.setValue("Business", forKey: "title", at: now) try await syncEngine.modifyRecords(scope: .private, saving: [remoteRecord]).notify() - let storedRecordIDs = syncEngine.private.database.state.withValue { + let storedRecordIDs = database.state.withValue { Set($0.storage.values.flatMap(\.records.keys)) } #expect(storedRecordIDs.contains(RemindersList.recordID(for: 1))) @@ -197,31 +199,8 @@ try #expect(RemindersList.fetchCount(db) == 2) } #expect(throws: CKError.self) { - try self.syncEngine.private.database.record(for: RemindersList.recordID(for: 1)) - } - } - - private func createDatabase( - rateLimit: Int = .max, - rateLimitDuration: Duration = .seconds(Int64.max) - ) throws -> MockCloudDatabase { - let database = MockCloudDatabase( - databaseScope: .private, - rateLimit: rateLimit, - rateLimitDuration: rateLimitDuration - ) - database.set(container: container) - try $_isRateLimitingDisabled.withValue(true) { - _ = try database.modifyRecordZones( - saving: [CKRecordZone(zoneID: recordID.zoneID)], - deleting: [] - ) - _ = try database.modifyRecords( - saving: [CKRecord(recordType: "A", recordID: recordID)], - deleting: [] - ) + try self.database.record(for: RemindersList.recordID(for: 1)) } - return database } } } diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 71ae478cf..2f20c96fa 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -55,8 +55,16 @@ ) ) try await prepareDatabase(userDatabase) - let privateDatabase = MockCloudDatabase(databaseScope: .private) - let sharedDatabase = MockCloudDatabase(databaseScope: .shared) + let privateDatabase = MockCloudDatabase( + databaseScope: .private, + rateLimit: rateLimit, + rateLimitDuration: rateLimitDuration + ) + let sharedDatabase = MockCloudDatabase( + databaseScope: .shared, + rateLimit: rateLimit, + rateLimitDuration: rateLimitDuration + ) let container = MockCloudContainer( accountStatus: accountStatus, containerIdentifier: testContainerIdentifier, From 0a70bd45bdb90e4ad1eeff5af67b60dd44af64ec Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Nov 2026 13:42:15 -0600 Subject: [PATCH 3/7] Fix <6.4 error. --- Package.resolved | 20 +++++++++---------- .../CloudKit/Internal/MockCloudDatabase.swift | 7 +++++++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/Package.resolved b/Package.resolved index c5ba82db2..6b63df759 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f2716017e30241f9c92ea950833ba4445053ca0764a53804f0294194b91564c3", + "originHash" : "c8fd2b80cb1fd62df8001e1fb88a97126371a8e40e8efdec291da8145dd78464", "pins" : [ { "identity" : "combine-schedulers", @@ -91,15 +91,6 @@ "version" : "1.1.1" } }, - { - "identity" : "swift-issue-reporting", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-issue-reporting", - "state" : { - "revision" : "71c7c9a761d1ca6ed4ccb6ced040fe1c1a39e8e7", - "version" : "2.1.0" - } - }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", @@ -144,6 +135,15 @@ "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", "version" : "603.0.2" } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "d9308ad679143c26a30dec52daefe82f54af2b82", + "version" : "1.13.1" + } } ], "version" : 3 diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 7c758a7db..02de4564a 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -24,6 +24,13 @@ lastRecordChangeTag += 1 return lastRecordChangeTag } + init( + rateLimit: Int, + rateLimitDuration: Duration + ) { + self.rateLimit = rateLimit + self.rateLimitDuration = rateLimitDuration + } } struct AssetID: Hashable { From d7cde6043ce74423c4532f48295baa7dee64aa35 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Nov 2026 14:02:52 -0600 Subject: [PATCH 4/7] Have record(for:) call records(for:) --- Examples/Reminders/Schema.swift | 4 +- .../CloudKit/Internal/MockCloudDatabase.swift | 54 +++++++++---------- .../CloudKit/Internal/Triggers.swift | 8 +-- Sources/SQLiteData/CloudKit/SyncEngine.swift | 2 +- .../CloudKitTests/SyncEngineTests.swift | 4 +- .../SQLiteDataTests/CustomFunctionTests.swift | 4 +- .../DatabaseFunctionTests.swift | 2 +- 7 files changed, 37 insertions(+), 41 deletions(-) diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index 1cf4747a0..e63f667a9 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -288,7 +288,7 @@ func appDatabase() throws -> any DatabaseWriter { try RemindersList.createTemporaryTrigger( after: .delete { _ in - Select($createDefaultRemindersList()) + Values($createDefaultRemindersList()) } when: { _ in !RemindersList.exists() } @@ -365,7 +365,7 @@ func appDatabase() throws -> any DatabaseWriter { after: .update { $0.status } forEachRow: { _, _ in - Select($handleReminderStatusUpdate()) + Values($handleReminderStatusUpdate()) } when: { _, new in new.status.eq(#bind(.completing)) } diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index 02de4564a..dfa544e23 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -63,30 +63,10 @@ } package func record(for recordID: CKRecord.ID) throws -> CKRecord { - let accountStatus = container.accountStatus() - guard accountStatus == .available - else { throw ckError(forAccountStatus: accountStatus) } - try updateRateLimit() - let record = try state.withValue { state in - guard let zone = state.storage[recordID.zoneID] - else { throw CKError(.zoneNotFound) } - guard let record = zone.records[recordID] - else { throw CKError(.unknownItem) } - guard let record = record.copy() as? CKRecord - else { fatalError("Could not copy CKRecord.") } - return record - } - - try state.withValue { state in - for key in record.allKeys() { - guard let assetData = state.assets[AssetID(recordID: record.recordID, key: key)] - else { continue } - let url = dataManager.wrappedValue.temporaryDirectory.appending(path: UUID().uuidString) - try dataManager.wrappedValue.save(assetData, to: url) - record[key] = CKAsset(fileURL: url) - } + guard let record = try records(for: [recordID], desiredKeys: nil).values.first?.get() + else { + throw CKError(.unknownItem) } - return record } @@ -98,17 +78,33 @@ guard accountStatus == .available else { throw ckError(forAccountStatus: accountStatus) } try updateRateLimit() - guard ids.count < 200 else { throw CKError(.limitExceeded) } - var results: [CKRecord.ID: Result] = [:] - $_isRateLimitingDisabled.withValue(true) { - for id in ids { - results[id] = Result { try record(for: id) } + return state.withValue { state in + var result: [CKRecord.ID: Result] = [:] + for recordID in ids { + result[recordID] = Result { + guard let zone = state.storage[recordID.zoneID] + else { throw CKError(.zoneNotFound) } + guard let record = zone.records[recordID] + else { throw CKError(.unknownItem) } + guard let record = record.copy() as? CKRecord + else { fatalError("Could not copy CKRecord.") } + + for key in record.allKeys() { + guard let assetData = state.assets[AssetID(recordID: record.recordID, key: key)] + else { continue } + let url = dataManager.wrappedValue.temporaryDirectory.appending(path: UUID().uuidString) + try dataManager.wrappedValue.save(assetData, to: url) + record[key] = CKAsset(fileURL: url) + } + + return record + } } + return result } - return results } package func modifyRecords( diff --git a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift index 37bd835e3..eebcafc2f 100644 --- a/Sources/SQLiteData/CloudKit/Internal/Triggers.swift +++ b/Sources/SQLiteData/CloudKit/Internal/Triggers.swift @@ -198,7 +198,7 @@ $0.parentRecordType ) } select: { - Select( + Values( #sql("\(new.primaryKey)"), T.tableName, zoneName ?? defaultZoneName, @@ -254,7 +254,7 @@ ifNotExists: true, after: .insert { new in validate(recordName: new.recordName) - Select( + Values( syncEngine.$didUpdate( recordName: new.recordName, zoneName: new.zoneName, @@ -314,7 +314,7 @@ } validate(recordName: new.recordName) - Select( + Values( syncEngine.$didUpdate( recordName: new.recordName, zoneName: new.zoneName, @@ -337,7 +337,7 @@ "\(String.sqliteDataCloudKitSchemaName)_after_delete_on_sqlitedata_icloud_metadata", ifNotExists: true, after: .update(of: \._isDeleted) { _, new in - Select( + Values( syncEngine.$didDelete( recordName: new.recordName, record: new.lastKnownServerRecord diff --git a/Sources/SQLiteData/CloudKit/SyncEngine.swift b/Sources/SQLiteData/CloudKit/SyncEngine.swift index 21a380aed..036ff3fa8 100644 --- a/Sources/SQLiteData/CloudKit/SyncEngine.swift +++ b/Sources/SQLiteData/CloudKit/SyncEngine.swift @@ -1530,7 +1530,7 @@ let unsyncedRecords = await withErrorReporting(.sqliteDataCloudKitFailure) { - var unsyncedRecordIDs = try await metadatabase.read { db in + var unsyncedRecordIDs = try await userDatabase.write { db in Set( try UnsyncedRecordID.all .fetchAll(db) diff --git a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift index e17c13620..da05cd11c 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/SyncEngineTests.swift @@ -124,7 +124,7 @@ withKnownIssue { _ = Reminder.createTemporaryTrigger( after: .insert { new in - Select(SyncEngine.isSynchronizing) + Values(SyncEngine.isSynchronizing) } ) } matching: { issue in @@ -137,7 +137,7 @@ } _ = Reminder.createTemporaryTrigger( after: .insert { new in - Select(SyncEngine.$isSynchronizing) + Values(SyncEngine.$isSynchronizing) } ) } diff --git a/Tests/SQLiteDataTests/CustomFunctionTests.swift b/Tests/SQLiteDataTests/CustomFunctionTests.swift index 897d1a53d..3c9436e88 100644 --- a/Tests/SQLiteDataTests/CustomFunctionTests.swift +++ b/Tests/SQLiteDataTests/CustomFunctionTests.swift @@ -15,7 +15,7 @@ import Testing } let database = try DatabaseQueue(configuration: configuration) let date = try database.read { db in - try Select($customDate()) + try Values($customDate()) .fetchOne(db) } #expect(date?.timeIntervalSinceReferenceDate == 0) @@ -25,7 +25,7 @@ import Testing } #expect(throws: (any Error).self) { try database.read { db in - _ = try Select($customDate()).fetchOne(db) + _ = try Values($customDate()).fetchOne(db) } } } diff --git a/Tests/SQLiteDataTests/DatabaseFunctionTests.swift b/Tests/SQLiteDataTests/DatabaseFunctionTests.swift index 9f4629f51..f4555e0b8 100644 --- a/Tests/SQLiteDataTests/DatabaseFunctionTests.swift +++ b/Tests/SQLiteDataTests/DatabaseFunctionTests.swift @@ -15,7 +15,7 @@ struct DatabaseFunctionTests { db.add(function: $exclaim) } let database = try DatabaseQueue(configuration: configuration) - assertQuery(Select($exclaim("Blob")), database: database) { + assertQuery(Values($exclaim("Blob")), database: database) { """ ┌─────────┐ │ "Blob!" │ From fcea1d54ec133b4d7a4de57b09b1206f396a4733 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Nov 2026 14:28:07 -0600 Subject: [PATCH 5/7] Add a CloudKitClock instead of using ContinuousClock. --- Package.resolved | 20 +++++++++---------- Package.swift | 2 ++ .../CloudKit/Internal/CloudKitClock.swift | 14 +++++++++++++ .../CloudKit/Internal/MockCloudDatabase.swift | 2 +- .../MockCloudDatabaseRateLimitTests.swift | 14 ++++++------- .../Internal/BaseCloudKitTests.swift | 2 ++ 6 files changed, 36 insertions(+), 18 deletions(-) create mode 100644 Sources/SQLiteData/CloudKit/Internal/CloudKitClock.swift diff --git a/Package.resolved b/Package.resolved index 6b63df759..6a5f9b45b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c8fd2b80cb1fd62df8001e1fb88a97126371a8e40e8efdec291da8145dd78464", + "originHash" : "f7503587ba7ade48448be5b58ac6834c7ebba67a5e6f166428a40a87b9921413", "pins" : [ { "identity" : "combine-schedulers", @@ -91,6 +91,15 @@ "version" : "1.1.1" } }, + { + "identity" : "swift-issue-reporting", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-issue-reporting", + "state" : { + "revision" : "b23bb4493c439d3e05152bc167014235ba3d47fc", + "version" : "2.1.2" + } + }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", @@ -135,15 +144,6 @@ "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", "version" : "603.0.2" } - }, - { - "identity" : "xctest-dynamic-overlay", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", - "state" : { - "revision" : "d9308ad679143c26a30dec52daefe82f54af2b82", - "version" : "1.13.1" - } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index bcd84fb3c..4ddbf90aa 100644 --- a/Package.swift +++ b/Package.swift @@ -61,6 +61,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), .package(url: "https://github.com/groue/GRDB.swift", from: "7.6.0"), + .package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.4.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), @@ -91,6 +92,7 @@ let package = Package( .target( name: "SQLiteData", dependencies: [ + .product(name: "Clocks", package: "swift-clocks"), .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "GRDB", package: "GRDB.swift"), diff --git a/Sources/SQLiteData/CloudKit/Internal/CloudKitClock.swift b/Sources/SQLiteData/CloudKit/Internal/CloudKitClock.swift new file mode 100644 index 000000000..f1c68896d --- /dev/null +++ b/Sources/SQLiteData/CloudKit/Internal/CloudKitClock.swift @@ -0,0 +1,14 @@ +import Clocks + +@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) +extension DependencyValues { + package var cloudKitClock: any Clock { + get { self[CloudKitClockKey.self] } + set { self[CloudKitClockKey.self] = newValue } + } + + private enum CloudKitClockKey: DependencyKey { + static let liveValue: any Clock = ContinuousClock() + static let testValue: any Clock = ContinuousClock() + } +} diff --git a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift index dfa544e23..6a964bd20 100644 --- a/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift +++ b/Sources/SQLiteData/CloudKit/Internal/MockCloudDatabase.swift @@ -49,7 +49,7 @@ rateLimitDuration: Duration = .seconds(1) ) { self.databaseScope = databaseScope - @Dependency(\.continuousClock) var clock + @Dependency(\.cloudKitClock) var clock self.clock = AnyClock(clock) self.state = LockIsolated(State(rateLimit: rateLimit, rateLimitDuration: rateLimitDuration)) } diff --git a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift index 67025ccc1..087d5358d 100644 --- a/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift +++ b/Tests/SQLiteDataTests/CloudKitTests/MockCloudDatabaseRateLimitTests.swift @@ -67,7 +67,7 @@ _ = try database.record(for: recordID) #expect(throws: CKError.self) { try database.record(for: self.recordID) } - await testClock.advance(by: .seconds(10)) + await cloudKitClock.advance(by: .seconds(10)) _ = try database.record(for: recordID) } @@ -75,10 +75,10 @@ @Test(.taskLocal($rateLimit, 2)) func slidingWindow() async throws { _ = try database.record(for: recordID) - await testClock.advance(by: .seconds(5)) + await cloudKitClock.advance(by: .seconds(5)) _ = try database.record(for: recordID) - await testClock.advance(by: .seconds(5)) + await cloudKitClock.advance(by: .seconds(5)) _ = try database.record(for: recordID) #expect(throws: CKError.self) { try database.record(for: self.recordID) } } @@ -87,10 +87,10 @@ @Test(.taskLocal($rateLimit, 1)) func rejectedRequestsCountAgainstRateLimit() async throws { _ = try database.record(for: recordID) - await testClock.advance(by: .seconds(5)) + await cloudKitClock.advance(by: .seconds(5)) #expect(throws: CKError.self) { try database.record(for: self.recordID) } - await testClock.advance(by: .seconds(5)) + await cloudKitClock.advance(by: .seconds(5)) #expect(throws: CKError.self) { try database.record(for: self.recordID) } } @@ -101,7 +101,7 @@ _ = try database.record(for: recordID) } for _ in 1...5 { - await testClock.advance(by: .seconds(1)) + await cloudKitClock.advance(by: .seconds(1)) #expect(throws: CKError.self) { try database.record(for: self.recordID) } } let error = #expect(throws: CKError.self) { @@ -109,7 +109,7 @@ } let retryAfterSeconds = try #require(error?.retryAfterSeconds) - await testClock.advance(by: .seconds(retryAfterSeconds)) + await cloudKitClock.advance(by: .seconds(retryAfterSeconds)) _ = try database.record(for: recordID) } diff --git a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift index 2f20c96fa..6c98d0c6b 100644 --- a/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift +++ b/Tests/SQLiteDataTests/Internal/BaseCloudKitTests.swift @@ -15,6 +15,7 @@ .dependencies { $0.currentTime.now = 0 $0.continuousClock = TestClock() + $0.cloudKitClock = TestClock() $0.dataManager = InMemoryDataManager() }, .taskLocal($attachMetadatabase, false) @@ -33,6 +34,7 @@ var testClock: TestClock { clock as! TestClock } + @Dependency(\.cloudKitClock, as: TestClock.self) var cloudKitClock @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) var container: MockCloudContainer { From 698c767411131bd6ee0f4aaf4b5772a2f62310bb Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Nov 2026 14:30:08 -0600 Subject: [PATCH 6/7] Update Package.swift --- Package@swift-6.0.swift | 2 ++ Package@swift-6.1.swift | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index 9fa29c556..2106c8d3d 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -23,6 +23,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), .package(url: "https://github.com/groue/GRDB.swift", from: "7.6.0"), + .package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), @@ -35,6 +36,7 @@ let package = Package( .target( name: "SQLiteData", dependencies: [ + .product(name: "Clocks", package: "swift-clocks"), .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "GRDB", package: "GRDB.swift"), diff --git a/Package@swift-6.1.swift b/Package@swift-6.1.swift index c0f8345fc..0f0b25c1c 100644 --- a/Package@swift-6.1.swift +++ b/Package@swift-6.1.swift @@ -61,6 +61,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), .package(url: "https://github.com/groue/GRDB.swift", from: "7.6.0"), + .package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.4.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.3"), .package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.9.0"), @@ -82,6 +83,7 @@ let package = Package( .target( name: "SQLiteData", dependencies: [ + .product(name: "Clocks", package: "swift-clocks"), .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "GRDB", package: "GRDB.swift"), From 14ee0e1d9cc4c3d97fb73e7b7ac538954e816ef4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Nov 2026 14:42:44 -0600 Subject: [PATCH 7/7] Fix import. --- Package.resolved | 20 +++++++++---------- .../CloudKit/Internal/CloudKitClock.swift | 1 + 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/Package.resolved b/Package.resolved index 6a5f9b45b..1e6800d8f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f7503587ba7ade48448be5b58ac6834c7ebba67a5e6f166428a40a87b9921413", + "originHash" : "dfd7e0f6bddf53d83bc4372f08b665b419a79785b6e33925d051440a0a13ec00", "pins" : [ { "identity" : "combine-schedulers", @@ -91,15 +91,6 @@ "version" : "1.1.1" } }, - { - "identity" : "swift-issue-reporting", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-issue-reporting", - "state" : { - "revision" : "b23bb4493c439d3e05152bc167014235ba3d47fc", - "version" : "2.1.2" - } - }, { "identity" : "swift-perception", "kind" : "remoteSourceControl", @@ -144,6 +135,15 @@ "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", "version" : "603.0.2" } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "d9308ad679143c26a30dec52daefe82f54af2b82", + "version" : "1.13.1" + } } ], "version" : 3 diff --git a/Sources/SQLiteData/CloudKit/Internal/CloudKitClock.swift b/Sources/SQLiteData/CloudKit/Internal/CloudKitClock.swift index f1c68896d..b934e9a6f 100644 --- a/Sources/SQLiteData/CloudKit/Internal/CloudKitClock.swift +++ b/Sources/SQLiteData/CloudKit/Internal/CloudKitClock.swift @@ -1,3 +1,4 @@ +import Dependencies import Clocks @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)