diff --git a/CHANGELOG.md b/CHANGELOG.md index eb71457..3923238 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ * Update core extension to 0.4.6 ([changelog](https://github.com/powersync-ja/powersync-sqlite-core/releases/tag/v0.4.6)) * Add `getCrudTransactions()`, returning an async sequence of transactions. * Compatibility with Swift 6.2 and XCode 26. +* Update minimum MacOS target to v12 +* Update minimum iOS target to v15 +* [Attachment Helpers] Added automatic verification or records' `local_uri` values on `AttachmentQueue` initialization. +initialization can be awaited with `AttachmentQueue.waitForInit()`. `AttachmentQueue.startSync()` also performs this verification. +`waitForInit()` is only recommended if `startSync` is not called directly after creating the queue. ## 1.5.1 diff --git a/Package.swift b/Package.swift index bd8db2e..797d586 100644 --- a/Package.swift +++ b/Package.swift @@ -52,8 +52,8 @@ if let corePath = localCoreExtension { let package = Package( name: packageName, platforms: [ - .iOS(.v13), - .macOS(.v10_15), + .iOS(.v15), + .macOS(.v12), .watchOS(.v9) ], products: [ diff --git a/Sources/PowerSync/attachments/AttachmentQueue.swift b/Sources/PowerSync/attachments/AttachmentQueue.swift index 2ab32c2..3435db0 100644 --- a/Sources/PowerSync/attachments/AttachmentQueue.swift +++ b/Sources/PowerSync/attachments/AttachmentQueue.swift @@ -10,6 +10,12 @@ public protocol AttachmentQueueProtocol: Sendable { var localStorage: any LocalStorageAdapter { get } var downloadAttachments: Bool { get } + /// Waits for automatically triggered initialization. + /// This ensures all attachment records have been verified before use. + /// The `startSync` method also performs this verification. This call is not + /// needed if `startSync` is called after creating the Attachment Queue. + func waitForInit() async throws + /// Starts the attachment sync process func startSync() async throws @@ -287,6 +293,9 @@ public actor AttachmentQueue: AttachmentQueueProtocol { private let _getLocalUri: @Sendable (_ filename: String) async -> String + private let initializedSubject = PassthroughSubject, Never>() + private var initializationResult: Result? + /// Initializes the attachment queue /// - Parameters match the stored properties public init( @@ -337,13 +346,44 @@ public actor AttachmentQueue: AttachmentQueueProtocol { syncThrottle: self.syncThrottleDuration ) + // Storing a reference to this task is non-trivial since we capture + // Self. Swift 6 Strict concurrency checking will complain about a nonisolated initializer Task { do { try await attachmentsService.withContext { context in try await self.verifyAttachments(context: context) } + await self.setInitializedResult(.success(())) } catch { self.logger.error("Error verifying attachments: \(error.localizedDescription)", tag: logTag) + await self.setInitializedResult(.failure(error)) + } + } + } + + /// Actor isolated method to set the initialization result + private func setInitializedResult(_ result: Result) { + initializationResult = result + initializedSubject.send(result) + } + + public func waitForInit() async throws { + if let isInitialized = initializationResult { + switch isInitialized { + case .success: + return + case let .failure(error): + throw error + } + } + + // Wait for the result asynchronously + for try await result in initializedSubject.values { + switch result { + case .success: + return + case let .failure(error): + throw error } } } diff --git a/Tests/PowerSyncTests/AttachmentTests.swift b/Tests/PowerSyncTests/AttachmentTests.swift index 46cb114..9aa40fb 100644 --- a/Tests/PowerSyncTests/AttachmentTests.swift +++ b/Tests/PowerSyncTests/AttachmentTests.swift @@ -173,6 +173,84 @@ final class AttachmentTests: XCTestCase { try await queue.clearQueue() try await queue.close() } + + func testAttachmentInitVerification() async throws { + actor MockRemoteStorage: RemoteStorageAdapter { + func uploadFile( + fileData _: Data, + attachment _: Attachment + ) async throws {} + + func downloadFile(attachment _: Attachment) async throws -> Data { + return Data([1, 2, 3]) + } + + func deleteFile(attachment _: Attachment) async throws {} + } + + // Create an attachments record which has an invalid local_uri + let attachmentsDirectory = getAttachmentDirectory() + + try FileManager.default.createDirectory( + at: URL(fileURLWithPath: attachmentsDirectory), + withIntermediateDirectories: true, + attributes: nil + ) + + let filename = "test.jpeg" + + try Data("1".utf8).write( + to: URL(fileURLWithPath: attachmentsDirectory).appendingPathComponent(filename) + ) + try await database.execute( + sql: """ + INSERT OR REPLACE INTO + attachments (id, timestamp, filename, local_uri, media_type, size, state, has_synced, meta_data) + VALUES + (uuid(), ?, ?, ?, ?, ?, ?, ?, ?) + """, + parameters: [ + Date().ISO8601Format(), + filename, + // This is a broken local_uri + URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("not_attachments/test.jpeg").path, + "application/jpeg", + 1, + AttachmentState.synced.rawValue, + 1, + "" + ] + ) + + let mockedRemote = MockRemoteStorage() + + let queue = AttachmentQueue( + db: database, + remoteStorage: mockedRemote, + attachmentsDirectory: attachmentsDirectory, + watchAttachments: { [database = database!] in try database.watch(options: WatchOptions( + sql: "SELECT photo_id FROM users WHERE photo_id IS NOT NULL", + mapper: { cursor in try WatchedAttachmentItem( + id: cursor.getString(name: "photo_id"), + fileExtension: "jpg" + ) } + )) } + ) + + try await queue.waitForInit() + + // the attachment should have been corrected in the init + let attachments = try await queue.attachmentsService.withContext { context in + try await context.getAttachments() + } + + guard let firstAttachment = attachments.first else { + XCTFail("Could not find the attachment record") + return + } + + XCTAssert(firstAttachment.localUri == URL(fileURLWithPath: attachmentsDirectory).appendingPathComponent(filename).path) + } } public enum WaitForMatchError: Error {