From baafffe12b0195af520d7cc6b5befbd209c8903b Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Sun, 6 Apr 2025 14:31:58 +0200 Subject: [PATCH 01/36] wip attachments --- Package.resolved | 9 - Package.swift | 3 +- Sources/PowerSync/Kotlin/KotlinAdapter.swift | 1 + .../PowerSync/attachments/Attachment.swift | 74 +++ .../attachments/AttachmentQueue.swift | 454 ++++++++++++++++++ .../attachments/AttachmentsService.swift | 261 ++++++++++ .../attachments/FileManagerLocalStorage.swift | 93 ++++ .../PowerSync/attachments/LocalStorage.swift | 72 +++ .../PowerSync/attachments/RemoteStorage.swift | 24 + .../attachments/SyncErrorHandler.swift | 54 +++ .../attachments/SyncingService.swift | 285 +++++++++++ 11 files changed, 1320 insertions(+), 10 deletions(-) create mode 100644 Sources/PowerSync/attachments/Attachment.swift create mode 100644 Sources/PowerSync/attachments/AttachmentQueue.swift create mode 100644 Sources/PowerSync/attachments/AttachmentsService.swift create mode 100644 Sources/PowerSync/attachments/FileManagerLocalStorage.swift create mode 100644 Sources/PowerSync/attachments/LocalStorage.swift create mode 100644 Sources/PowerSync/attachments/RemoteStorage.swift create mode 100644 Sources/PowerSync/attachments/SyncErrorHandler.swift create mode 100644 Sources/PowerSync/attachments/SyncingService.swift diff --git a/Package.resolved b/Package.resolved index c1c16d3..32c002c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,14 +1,5 @@ { "pins" : [ - { - "identity" : "powersync-kotlin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/powersync-ja/powersync-kotlin.git", - "state" : { - "revision" : "443df078f4b9352de137000b993d564d4ab019b7", - "version" : "1.0.0-BETA28.0" - } - }, { "identity" : "powersync-sqlite-core-swift", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index e870748..3ac3562 100644 --- a/Package.swift +++ b/Package.swift @@ -16,7 +16,8 @@ let package = Package( targets: ["PowerSync"]), ], dependencies: [ - .package(url: "https://github.com/powersync-ja/powersync-kotlin.git", exact: "1.0.0-BETA28.0"), + .package(path: "/Users/stevenontong/Documents/platform_code/powersync/powersync-kotlin"), + // .package(url: "https://github.com/powersync-ja/powersync-kotlin.git", exact: "1.0.0-BETA28.0"), .package(url: "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "0.3.12"..<"0.4.0") ], targets: [ diff --git a/Sources/PowerSync/Kotlin/KotlinAdapter.swift b/Sources/PowerSync/Kotlin/KotlinAdapter.swift index 8f864de..0418709 100644 --- a/Sources/PowerSync/Kotlin/KotlinAdapter.swift +++ b/Sources/PowerSync/Kotlin/KotlinAdapter.swift @@ -1,5 +1,6 @@ import PowerSyncKotlin + internal struct KotlinAdapter { struct Index { static func toKotlin(_ index: IndexProtocol) -> PowerSyncKotlin.Index { diff --git a/Sources/PowerSync/attachments/Attachment.swift b/Sources/PowerSync/attachments/Attachment.swift new file mode 100644 index 0000000..b8bd3bd --- /dev/null +++ b/Sources/PowerSync/attachments/Attachment.swift @@ -0,0 +1,74 @@ +/** + * Enum for the attachment state + */ +public enum AttachmentState: Int { + case queuedDownload + case queuedUpload + case queuedDelete + case synced + case archived +} + +/** + * Struct representing an attachment + */ +public struct Attachment { + let id: String + let timestamp: Int + let filename: String + let state: Int + let localUri: String? + let mediaType: String? + let size: Int64? + /** + * Specifies if the attachment has been synced locally before. This is particularly useful + * for restoring archived attachments in edge cases. + */ + let hasSynced: Int? + + public init( + id: String, + filename: String, + state: Int, + timestamp: Int = 0, + hasSynced: Int? = 0, + localUri: String? = nil, + mediaType: String? = nil, + size: Int64? = nil, + ) { + self.id = id + self.timestamp = timestamp + self.filename = filename + self.state = state + self.localUri = localUri + self.mediaType = mediaType + self.size = size + self.hasSynced = hasSynced + } + + func with(filename: String? = nil, state: Int? = nil, hasSynced: Int? = nil, localUri: String? = nil, mediaType: String? = nil, size: Int64? = nil ) -> Attachment { + return Attachment( + id: self.id, + filename: self.filename, + state: state ?? self.state, + hasSynced: hasSynced ?? self.hasSynced, + localUri: localUri ?? self.localUri, + mediaType: mediaType ?? self.mediaType, + size: size ?? self.size, + ) + } + + public static func fromCursor(_ cursor: SqlCursor) throws -> Attachment { + return Attachment( + id: try cursor.getString(name: "id"), + filename: try cursor.getString(name: "filename"), + state: try cursor.getLong(name: "state"), + timestamp: try cursor.getLong(name: "timestamp"), + hasSynced: try cursor.getLongOptional(name: "has_synced"), + localUri: try cursor.getStringOptional(name: "local_uri"), + mediaType: try cursor.getStringOptional(name: "media_type"), + size: try cursor.getLongOptional(name: "size")?.int64Value, + ) + } +} + diff --git a/Sources/PowerSync/attachments/AttachmentQueue.swift b/Sources/PowerSync/attachments/AttachmentQueue.swift new file mode 100644 index 0000000..5e38ea4 --- /dev/null +++ b/Sources/PowerSync/attachments/AttachmentQueue.swift @@ -0,0 +1,454 @@ +import Foundation +import Combine +import OSLog + +//TODO should not need this +import PowerSyncKotlin + +/** + * A watched attachment record item. + * This is usually returned from watching all relevant attachment IDs. + */ +public struct WatchedAttachmentItem { + /** + * Id for the attachment record + */ + public let id: String + + /** + * File extension used to determine an internal filename for storage if no `filename` is provided + */ + public let fileExtension: String? + + /** + * Filename to store the attachment with + */ + public let filename: String? + + public init(id: String, fileExtension: String? = nil, filename: String? = nil) { + self.id = id + self.fileExtension = fileExtension + self.filename = filename + + precondition(fileExtension != nil || filename != nil, "Either fileExtension or filename must be provided.") + } +} + + +/** + * Class used to implement the attachment queue + * Requires a PowerSyncDatabase, an implementation of + * RemoteStorageAdapter and an attachment directory name which will + * determine which folder attachments are stored into. + */ +public actor AttachmentQueue { + public static let DEFAULT_TABLE_NAME = "attachments" + public static let DEFAULT_ATTACHMENTS_DIRECTORY_NAME = "attachments" + + /** + * PowerSync database client + */ + public let db: PowerSyncDatabaseProtocol + + /** + * Adapter which interfaces with the remote storage backend + */ + public let remoteStorage: RemoteStorageAdapter + + /** + * Directory name where attachment files will be written to disk. + * This will be created if it does not exist + */ + private let attachmentDirectory: String + + /** + * A publisher for the current state of local attachments + */ + private let watchedAttachments: AsyncThrowingStream<[WatchedAttachmentItem], Error> + + /** + * Provides access to local filesystem storage methods + */ + public let localStorage: LocalStorageAdapter + + /** + * SQLite table where attachment state will be recorded + */ + private let attachmentsQueueTableName: String + + /** + * Attachment operation error handler. This specified if failed attachment operations + * should be retried. + */ + private let errorHandler: SyncErrorHandler? + + /** + * Periodic interval to trigger attachment sync operations + */ + private let syncInterval: TimeInterval + + /** + * Archived attachments can be used as a cache which can be restored if an attachment id + * reappears after being removed. This parameter defines how many archived records are retained. + * Records are deleted once the number of items exceeds this value. + */ + private let archivedCacheLimit: Int64 + + /** + * Throttles remote sync operations triggering + */ + private let syncThrottleDuration: TimeInterval + + /** + * Creates a list of subdirectories in the attachmentDirectory + */ + private let subdirectories: [String]? + + /** + * Should attachments be downloaded + */ + private let downloadAttachments: Bool + + /** + * Logging interface used for all log operations + */ +// public let logger: Logger + + /** + * Service which provides access to attachment records. + * Use this to: + * - Query all current attachment records + * - Create new attachment records for upload/download + */ + public let attachmentsService: AttachmentService + + private var syncStatusTask: Task? + private let mutex = NSLock() + private var cancellables = Set() + + public private(set) var closed: Bool = false + + /** + * Syncing service for this attachment queue. + * This processes attachment records and performs relevant upload, download and delete + * operations. + */ + private(set) lazy var syncingService: SyncingService = { + return SyncingService( + remoteStorage: self.remoteStorage, + localStorage: self.localStorage, + attachmentsService: self.attachmentsService, + getLocalUri: { [weak self] filename in + guard let self = self else { return filename } + return await self.getLocalUri(filename) + }, + errorHandler: self.errorHandler, + syncThrottle: self.syncThrottleDuration + ) + }() + + public init( + db: PowerSyncDatabaseProtocol, + remoteStorage: RemoteStorageAdapter, + attachmentDirectory: String, + watchedAttachments: AsyncThrowingStream<[WatchedAttachmentItem], Error>, + localStorage: LocalStorageAdapter = FileManagerStorageAdapter(), + attachmentsQueueTableName: String = DEFAULT_TABLE_NAME, + errorHandler: SyncErrorHandler? = nil, + syncInterval: TimeInterval = 30.0, + archivedCacheLimit: Int64 = 100, + syncThrottleDuration: TimeInterval = 1.0, + subdirectories: [String]? = nil, + downloadAttachments: Bool = true, +// logger: Logger = Logger(subsystem: "com.powersync.attachments", category: "AttachmentQueue") + ) { + self.db = db + self.remoteStorage = remoteStorage + self.attachmentDirectory = attachmentDirectory + self.watchedAttachments = watchedAttachments + self.localStorage = localStorage + self.attachmentsQueueTableName = attachmentsQueueTableName + self.errorHandler = errorHandler + self.syncInterval = syncInterval + self.archivedCacheLimit = archivedCacheLimit + self.syncThrottleDuration = syncThrottleDuration + self.subdirectories = subdirectories + self.downloadAttachments = downloadAttachments +// self.logger = logger + + self.attachmentsService = AttachmentService( + db: db, + tableName: attachmentsQueueTableName, +// logger: logger, + maxArchivedCount: archivedCacheLimit + ) + } + + /** + * Initialize the attachment queue by + * 1. Creating attachments directory + * 2. Adding watches for uploads, downloads, and deletes + * 3. Adding trigger to run uploads, downloads, and deletes when device is online after being offline + */ + public func startSync() async throws { + if closed { + throw NSError(domain: "AttachmentError", code: 4, userInfo: [NSLocalizedDescriptionKey: "Attachment queue has been closed"]) + } + + // Ensure the directory where attachments are downloaded exists + try await localStorage.makeDir(path: attachmentDirectory) + + if let subdirectories = subdirectories { + for subdirectory in subdirectories { + let path = URL(fileURLWithPath: attachmentDirectory).appendingPathComponent(subdirectory).path + try await localStorage.makeDir(path: path) + } + } + + await syncingService.startPeriodicSync(period: syncInterval) + + syncStatusTask = Task { + do { + // Create a task for watching connectivity changes + let connectivityTask = Task { + var previousConnected = db.currentStatus.connected + + for await status in db.currentStatus.asFlow() { + if !previousConnected && status.connected { + await syncingService.triggerSync() + } + previousConnected = status.connected + } + } + + // Create a task for watching attachment changes + let watchTask = Task { + for try await items in self.watchedAttachments { + try await self.processWatchedAttachments(items: items) + } + } + + // Wait for both tasks to complete (they shouldn't unless cancelled) + await connectivityTask.value + try await watchTask.value + } catch { + if !(error is CancellationError) { +// logger.error("Error in sync job: \(error.localizedDescription)") + } + } + } + } + + public func close() async throws { + if closed { + return + } + + syncStatusTask?.cancel() + await syncingService.close() + closed = true + } + + /** + * Resolves the filename for new attachment items. + * A new attachment from watchedAttachments might not include a filename. + * Concatenates the attachment ID and extension by default. + * This method can be overridden for custom behavior. + */ + public func resolveNewAttachmentFilename( + attachmentId: String, + fileExtension: String? + ) -> String { + return "\(attachmentId).\(fileExtension ?? "")" + } + + /** + * Processes attachment items returned from watchedAttachments. + * The default implementation asserts the items returned from watchedAttachments as the definitive + * state for local attachments. + * + * Records currently in the attachment queue which are not present in the items are deleted from + * the queue. + * + * Received items which are not currently in the attachment queue are assumed scheduled for + * download. This requires that locally created attachments should be created with saveFile + * before assigning the attachment ID to the relevant watched tables. + * + * This method can be overridden for custom behavior. + */ + public func processWatchedAttachments(items: [WatchedAttachmentItem]) async throws { + // Need to get all the attachments which are tracked in the DB. + // We might need to restore an archived attachment. + let currentAttachments = try await attachmentsService.getAttachments() + var attachmentUpdates = [Attachment]() + + for item in items { + let existingQueueItem = currentAttachments.first { $0.id == item.id } + + if existingQueueItem == nil { + if !downloadAttachments { + continue + } + // This item should be added to the queue + // This item is assumed to be coming from an upstream sync + // Locally created new items should be persisted using saveFile before + // this point. + let filename = resolveNewAttachmentFilename( + attachmentId: item.id, + fileExtension: item.fileExtension + ) + + attachmentUpdates.append( + Attachment( + id: item.id, + filename: filename, + state: AttachmentState.queuedDownload.rawValue + ) + ) + } else if existingQueueItem!.state == AttachmentState.archived.rawValue { + // The attachment is present again. Need to queue it for sync. + // We might be able to optimize this in future + if existingQueueItem!.hasSynced == 1 { + // No remote action required, we can restore the record (avoids deletion) + attachmentUpdates.append( + existingQueueItem!.with(state: AttachmentState.synced.rawValue) + ) + } else { + /** + * The localURI should be set if the record was meant to be downloaded + * and has been synced. If it's missing and hasSynced is false then + * it must be an upload operation + */ + let newState = existingQueueItem!.localUri == nil ? + AttachmentState.queuedDownload.rawValue : + AttachmentState.queuedUpload.rawValue + + attachmentUpdates.append( + existingQueueItem!.with(state: newState) + ) + } + } + } + + /** + * Archive any items not specified in the watched items except for items pending delete. + */ + for attachment in currentAttachments { + if attachment.state != AttachmentState.queuedDelete.rawValue && + items.first(where: { $0.id == attachment.id }) == nil { + attachmentUpdates.append( + attachment.with(state: AttachmentState.archived.rawValue) + ) + } + } + + if !attachmentUpdates.isEmpty { + try await attachmentsService.saveAttachments(attachments: attachmentUpdates) + } + } + + /** + * A function which creates a new attachment locally. This new attachment is queued for upload + * after creation. + * + * The filename is resolved using resolveNewAttachmentFilename. + * + * A updateHook is provided which should be used when assigning relationships to the newly + * created attachment. This hook is executed in the same writeTransaction which creates the + * attachment record. + * + * This method can be overridden for custom behavior. + */ + public func saveFile( + data: Data, + mediaType: String, + fileExtension: String?, + updateHook: ((PowerSyncTransaction, Attachment) throws -> Void)? = nil + ) async throws -> Attachment { + let id = try await db.get(sql: "SELECT uuid() as id", parameters: [], mapper: { cursor in + try cursor.getString(name: "id") }) + + let filename = resolveNewAttachmentFilename(attachmentId: id, fileExtension: fileExtension) + let localUri = getLocalUri(filename) + + // Write the file to the filesystem + let fileSize = try await localStorage.saveFile(filePath: localUri, data: data) + + // Start a write transaction. The attachment record and relevant local relationship + // assignment should happen in the same transaction. + return try await db.writeTransaction { tx in + let attachment = Attachment( + id: id, + filename: filename, + state: AttachmentState.queuedUpload.rawValue, + localUri: localUri, + mediaType: mediaType, + size: fileSize + ) + + // Allow consumers to set relationships to this attachment id + try updateHook?(tx, attachment) + + return try self.attachmentsService.upsertAttachment(attachment, context: tx) + } + } + + /** + * A function which creates an attachment delete operation locally. This operation is queued + * for delete. + * The default implementation assumes the attachment record already exists locally. An exception + * is thrown if the record does not exist locally. + * This method can be overridden for custom behavior. + */ + public func deleteFile( + attachmentId: String, + updateHook: ((ConnectionContext, Attachment) throws -> Void)? = nil + ) async throws -> Attachment { + guard let attachment = try await attachmentsService.getAttachment(id: attachmentId) else { + throw NSError(domain: "AttachmentError", code: 5, userInfo: [NSLocalizedDescriptionKey: "Attachment record with id \(attachmentId) was not found."]) + } + + return try await db.writeTransaction { tx in + try updateHook?(tx, attachment) + + let updatedAttachment = Attachment( + id: attachment.id, + filename: attachment.filename, + state: AttachmentState.queuedDelete.rawValue, + hasSynced: attachment.hasSynced, + localUri: attachment.localUri, + mediaType: attachment.mediaType, + size: attachment.size, + ) + + return try self.attachmentsService.upsertAttachment(updatedAttachment, context: tx) + } + } + + /** + * Return user's storage directory with the attachmentPath used to load the file. + * Example: filePath: "attachment-1.jpg" returns "/path/to/Documents/attachments/attachment-1.jpg" + */ + public func getLocalUri(_ filename: String) -> String { + return URL(fileURLWithPath: attachmentDirectory).appendingPathComponent(filename).path + } + + /** + * Removes all archived items + */ + public func expireCache() async throws { + var done = false + repeat { + done = try await self.syncingService.deleteArchivedAttachments() + } while !done + } + + /** + * Clears the attachment queue and deletes all attachment files + */ + public func clearQueue() async throws { + try await attachmentsService.clearQueue() + // Remove the attachments directory + try await localStorage.rmDir(path: attachmentDirectory) + } +} diff --git a/Sources/PowerSync/attachments/AttachmentsService.swift b/Sources/PowerSync/attachments/AttachmentsService.swift new file mode 100644 index 0000000..0931e85 --- /dev/null +++ b/Sources/PowerSync/attachments/AttachmentsService.swift @@ -0,0 +1,261 @@ +import Foundation +//TODO should not need this +import PowerSyncKotlin + +/** + * Service for interacting with the local attachment records. + */ +public class AttachmentService { + private let db: any PowerSyncDatabaseProtocol + private let tableName: String +// private let logger: Logger + private let maxArchivedCount: Int64 + + /** + * Table used for storing attachments in the attachment queue. + */ + private var table: String { + return tableName + } + + public init( + db: PowerSyncDatabaseProtocol, + tableName: String, +// logger: Logger, + maxArchivedCount: Int64 + ) { + self.db = db + self.tableName = tableName +// self.logger = logger + self.maxArchivedCount = maxArchivedCount + } + + /** + * Delete the attachment from the attachment queue. + */ + public func deleteAttachment(id: String) async throws { + _ = try await db.execute(sql: "DELETE FROM \(table) WHERE id = ?", parameters: [id]) + } + + /** + * Set the state of the attachment to ignore. + */ + public func ignoreAttachment(id: String) async throws { + _ = try await db.execute( + sql: "UPDATE \(table) SET state = ? WHERE id = ?", + parameters: [AttachmentState.archived.rawValue, id] + ) + } + + /** + * Get the attachment from the attachment queue using an ID. + */ + public func getAttachment(id: String) async throws -> Attachment? { + return try await db.getOptional(sql: "SELECT * FROM \(table) WHERE id = ?", parameters: [id], mapper: { cursor in + try Attachment.fromCursor(cursor) + }) + } + + /** + * Save the attachment to the attachment queue. + */ + public func saveAttachment(attachment: Attachment) async throws -> Attachment { + return try await db.writeTransaction { ctx in + try self.upsertAttachment(attachment, context: ctx) + } + } + + /** + * Save the attachments to the attachment queue. + */ + public func saveAttachments(attachments: [Attachment]) async throws { + if attachments.isEmpty { + return + } + + try await db.writeTransaction { tx in + for attachment in attachments { + _ = try self.upsertAttachment(attachment, context: tx) + } + } + } + + /** + * Get all the ID's of attachments in the attachment queue. + */ + public func getAttachmentIds() async throws -> [String] { + return try await db.getAll( + sql: "SELECT id FROM \(table) WHERE id IS NOT NULL", + parameters: [], + mapper: {cursor in + try cursor.getString(name: "id") + }) + } + + public func getAttachments() async throws -> [Attachment] { + return try await db.getAll( + sql: """ + SELECT + * + FROM + \(table) + WHERE + id IS NOT NULL + ORDER BY + timestamp ASC + """, + parameters: [], + mapper: { cursor in + try Attachment.fromCursor(cursor) + }) + } + + /** + * Gets all the active attachments which require an operation to be performed. + */ + public func getActiveAttachments() async throws -> [Attachment] { + return try await db.getAll( + sql: """ + SELECT + * + FROM + \(table) + WHERE + state = ? + OR state = ? + OR state = ? + ORDER BY + timestamp ASC + """, + parameters: [ + AttachmentState.queuedUpload.rawValue, + AttachmentState.queuedDownload.rawValue, + AttachmentState.queuedDelete.rawValue, + ] + ) { cursor in + try Attachment.fromCursor(cursor) + } + } + + /** + * Watcher for changes to attachments table. + * Once a change is detected it will initiate a sync of the attachments + */ + public func watchActiveAttachments() throws -> AsyncThrowingStream<[String], Error> { +// logger.i("Watching attachments...") + + return try db.watch( + sql: """ + SELECT + id + FROM + \(table) + WHERE + state = ? + OR state = ? + OR state = ? + ORDER BY + timestamp ASC + """, + parameters: [ + AttachmentState.queuedUpload.rawValue, + AttachmentState.queuedDownload.rawValue, + AttachmentState.queuedDelete.rawValue, + ] + ) { cursor in + try cursor.getString(name: "id") + } + } + + /** + * Helper function to clear the attachment queue + * Currently only used for testing purposes. + */ + public func clearQueue() async throws { + //logger.i("Clearing attachment queue...") + _ = try await db.execute("DELETE FROM \(table)") + } + + /** + * Delete attachments which have been archived + * @returns true if all items have been deleted. Returns false if there might be more archived + * items remaining. + */ + public func deleteArchivedAttachments(callback: @escaping ([Attachment]) async throws -> Void) async throws -> Bool { + // First fetch the attachments in order to allow other cleanup + let limit = 1000 + let attachments = try await db.getAll( + sql: """ + SELECT + * + FROM + \(table) + WHERE + state = ? + ORDER BY + timestamp DESC + LIMIT ? OFFSET ? + """, + parameters: [ + AttachmentState.archived.rawValue, + limit, + maxArchivedCount, + ] + ) { cursor in + try Attachment.fromCursor(cursor) + } + + try await callback(attachments) + + let ids = try JSONEncoder().encode(attachments.map { $0.id }) + let idsString = String(data: ids, encoding: .utf8)! + + _ = try await db.execute( + sql: "DELETE FROM \(table) WHERE id IN (SELECT value FROM json_each(?));", + parameters: [idsString] + ) + + return attachments.count < limit + } + + /** + * Upserts an attachment record synchronously given a database connection context. + */ + public func upsertAttachment( + _ attachment: Attachment, + context: PowerSyncTransaction + ) throws -> Attachment { + let timestamp = Int(Date().timeIntervalSince1970 * 1000) + let updatedRecord = Attachment( + id: attachment.id, + filename: attachment.filename, + state: attachment.state, + timestamp: timestamp, + hasSynced: attachment.hasSynced, + localUri: attachment.localUri, + mediaType: attachment.mediaType, + size: attachment.size, + ) + + try context.execute( + sql: """ + INSERT OR REPLACE INTO + \(table) (id, timestamp, filename, local_uri, media_type, size, state, has_synced) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?) + """, + parameters: [ + updatedRecord.id, + updatedRecord.timestamp, + updatedRecord.filename, + updatedRecord.localUri as Any, + updatedRecord.mediaType ?? NSNull(), + updatedRecord.size ?? NSNull(), + updatedRecord.state, + updatedRecord.hasSynced ?? 0, + ] + ) + + return attachment + } +} diff --git a/Sources/PowerSync/attachments/FileManagerLocalStorage.swift b/Sources/PowerSync/attachments/FileManagerLocalStorage.swift new file mode 100644 index 0000000..3952104 --- /dev/null +++ b/Sources/PowerSync/attachments/FileManagerLocalStorage.swift @@ -0,0 +1,93 @@ +import Foundation + +/** + * Implementation of LocalStorageAdapter using FileManager + */ +public class FileManagerStorageAdapter: LocalStorageAdapter { + private let fileManager = FileManager.default + + public init () {} + + public func saveFile(filePath: String, data: Data) async throws -> Int64 { + return try await Task { + let url = URL(fileURLWithPath: filePath) + + // Make sure the parent directory exists + try fileManager.createDirectory(at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + + // Write data to file + try data.write(to: url) + + // Return the size of the data + return Int64(data.count) + }.value + } + + public func readFile(filePath: String, mediaType: String?) async throws -> Data { + return try await Task { + let url = URL(fileURLWithPath: filePath) + + if !fileManager.fileExists(atPath: filePath) { + throw PowerSyncError.fileNotFound(filePath) + } + + // Read data from file + do { + return try Data(contentsOf: url) + } catch { + throw PowerSyncError.ioError(error) + } + }.value + } + + public func deleteFile(filePath: String) async throws { + try await Task { + if fileManager.fileExists(atPath: filePath) { + try fileManager.removeItem(atPath: filePath) + } + }.value + } + + public func fileExists(filePath: String) async throws -> Bool { + return await Task { + return fileManager.fileExists(atPath: filePath) + }.value + } + + public func makeDir(path: String) async throws { + try await Task { + try fileManager.createDirectory(atPath: path, + withIntermediateDirectories: true, + attributes: nil) + }.value + } + + public func rmDir(path: String) async throws { + try await Task { + if fileManager.fileExists(atPath: path) { + try fileManager.removeItem(atPath: path) + } + }.value + } + + public func copyFile(sourcePath: String, targetPath: String) async throws { + try await Task { + if !fileManager.fileExists(atPath: sourcePath) { + throw PowerSyncError.fileNotFound(sourcePath) + } + + // Ensure target directory exists + let targetUrl = URL(fileURLWithPath: targetPath) + try fileManager.createDirectory(at: targetUrl.deletingLastPathComponent(), + withIntermediateDirectories: true) + + // If target already exists, remove it first + if fileManager.fileExists(atPath: targetPath) { + try fileManager.removeItem(atPath: targetPath) + } + + try fileManager.copyItem(atPath: sourcePath, toPath: targetPath) + }.value + } +} diff --git a/Sources/PowerSync/attachments/LocalStorage.swift b/Sources/PowerSync/attachments/LocalStorage.swift new file mode 100644 index 0000000..ccfe839 --- /dev/null +++ b/Sources/PowerSync/attachments/LocalStorage.swift @@ -0,0 +1,72 @@ +import Foundation + +/** + * Error type for PowerSync operations + */ +public enum PowerSyncError: Error { + case generalError(String) + case fileNotFound(String) + case ioError(Error) + case invalidPath(String) + case attachmentError(String) +} + +/** + * Storage adapter for local storage + */ +public protocol LocalStorageAdapter { + /** + * Saves data to a file at the specified path. + * @returns the bytesize of the file + */ + func saveFile( + filePath: String, + data: Data + ) async throws -> Int64 + + /** + * Reads a file from the specified path. + */ + func readFile( + filePath: String, + mediaType: String? + ) async throws -> Data + + /** + * Deletes a file at the specified path. + */ + func deleteFile(filePath: String) async throws + + /** + * Checks if a file exists at the specified path. + */ + func fileExists(filePath: String) async throws -> Bool + + /** + * Creates a directory at the specified path. + */ + func makeDir(path: String) async throws + + /** + * Removes a directory at the specified path. + */ + func rmDir(path: String) async throws + + /** + * Copies a file from source path to target path. + */ + func copyFile( + sourcePath: String, + targetPath: String + ) async throws +} + +/** + * Extension providing default parameter for readFile + */ +public extension LocalStorageAdapter { + func readFile(filePath: String) async throws -> Data { + return try await readFile(filePath: filePath, mediaType: nil) + } +} + diff --git a/Sources/PowerSync/attachments/RemoteStorage.swift b/Sources/PowerSync/attachments/RemoteStorage.swift new file mode 100644 index 0000000..d75f104 --- /dev/null +++ b/Sources/PowerSync/attachments/RemoteStorage.swift @@ -0,0 +1,24 @@ +import Foundation + +/** + * Adapter for interfacing with remote attachment storage. + */ +public protocol RemoteStorageAdapter { + /** + * Upload a file to remote storage + */ + func uploadFile( + fileData: Data, + attachment: Attachment + ) async throws + + /** + * Download a file from remote storage + */ + func downloadFile(attachment: Attachment) async throws -> Data + + /** + * Delete a file from remote storage + */ + func deleteFile(attachment: Attachment) async throws +} diff --git a/Sources/PowerSync/attachments/SyncErrorHandler.swift b/Sources/PowerSync/attachments/SyncErrorHandler.swift new file mode 100644 index 0000000..3624552 --- /dev/null +++ b/Sources/PowerSync/attachments/SyncErrorHandler.swift @@ -0,0 +1,54 @@ +import Foundation + +/** + * Handles attachment operation errors. + * The handlers here specify if the corresponding operations should be retried. + * Attachment records are archived if an operation failed and should not be retried. + */ +public protocol SyncErrorHandler { + /** + * @returns if the provided attachment download operation should be retried + */ + func onDownloadError( + attachment: Attachment, + error: Error + ) async -> Bool + + /** + * @returns if the provided attachment upload operation should be retried + */ + func onUploadError( + attachment: Attachment, + error: Error + ) async -> Bool + + /** + * @returns if the provided attachment delete operation should be retried + */ + func onDeleteError( + attachment: Attachment, + error: Error + ) async -> Bool +} + +/** + * Default implementation of SyncErrorHandler + */ +public class DefaultSyncErrorHandler: SyncErrorHandler { + public init() {} + + public func onDownloadError(attachment: Attachment, error: Error) async -> Bool { + // Default implementation could log the error and determine retry based on error type + return false // Don't retry by default + } + + public func onUploadError(attachment: Attachment, error: Error) async -> Bool { + // Default implementation could log the error and determine retry based on error type + return false // Don't retry by default + } + + public func onDeleteError(attachment: Attachment, error: Error) async -> Bool { + // Default implementation could log the error and determine retry based on error type + return false // Don't retry by default + } +} diff --git a/Sources/PowerSync/attachments/SyncingService.swift b/Sources/PowerSync/attachments/SyncingService.swift new file mode 100644 index 0000000..3fba8b4 --- /dev/null +++ b/Sources/PowerSync/attachments/SyncingService.swift @@ -0,0 +1,285 @@ +import Foundation +import Combine + +/** + * Service used to sync attachments between local and remote storage + */ +actor SyncingService { + private let remoteStorage: RemoteStorageAdapter + private let localStorage: LocalStorageAdapter + private let attachmentsService: AttachmentService + private let getLocalUri: (String) async -> String + private let errorHandler: SyncErrorHandler? +// private let logger: Logger + private let syncThrottle: TimeInterval + private var cancellables = Set() + private let syncTriggerSubject = PassthroughSubject() + private var periodicSyncTimer: Timer? + private var syncTask: Task? + + init( + remoteStorage: RemoteStorageAdapter, + localStorage: LocalStorageAdapter, + attachmentsService: AttachmentService, + getLocalUri: @escaping (String) async -> String, + errorHandler: SyncErrorHandler? = nil, +// logger: Logger, + syncThrottle: TimeInterval = 5.0 + ) { + self.remoteStorage = remoteStorage + self.localStorage = localStorage + self.attachmentsService = attachmentsService + self.getLocalUri = getLocalUri + self.errorHandler = errorHandler +// self.logger = logger + self.syncThrottle = syncThrottle + + // We use an actor for synchronisation. + // This needs to be executed in a non-isolated environment during init + Task { await self.setupSyncFlow() } + } + + + private func setupSyncFlow() { + // Create a Task that will process sync events + syncTask = Task { + // Create an AsyncStream from the syncTriggerSubject + let syncTrigger = AsyncStream { continuation in + let cancellable = syncTriggerSubject + .throttle(for: .seconds(syncThrottle), scheduler: DispatchQueue.global(), latest: true) + .sink { _ in continuation.yield(()) } + + continuation.onTermination = { _ in + cancellable.cancel() + } + self.cancellables.insert(cancellable) + } + + // Create a task that watches for active attachments + let watchTask = Task { + for try await _ in try attachmentsService.watchActiveAttachments() { + // When an attachment changes, trigger a sync + syncTriggerSubject.send(()) + } + } + + // Process sync triggers + for await _ in syncTrigger { + guard !Task.isCancelled else { break } + + do { + // Process active attachments + let attachments = try await attachmentsService.getActiveAttachments() + try await handleSync(attachments: attachments) + + // Cleanup archived attachments + _ = try await deleteArchivedAttachments() + } catch { + if error is CancellationError { + break + } +// logger.error("Caught exception when processing attachments: \(error)") + } + } + + // Clean up the watch task when we're done + watchTask.cancel() + } + } + + func startPeriodicSync(period: TimeInterval) async { + // Cancel existing timer if any + if let timer = periodicSyncTimer { + timer.invalidate() + periodicSyncTimer = nil + } + + // Create a new timer on the main actor and store the reference + periodicSyncTimer = Timer.scheduledTimer(withTimeInterval: period, repeats: true) { [weak self] _ in + guard let self = self else { return } + Task { + await self.triggerSync() + } + } + + // Trigger initial sync + await triggerSync() + } + + func triggerSync() async { + // This is safe to call from outside the actor + syncTriggerSubject.send(()) + } + + func close() async { + // Cancel and clean up timer + if let timer = periodicSyncTimer { + timer.invalidate() + periodicSyncTimer = nil + } + + // Cancel the sync task + syncTask?.cancel() + syncTask = nil + + // Clean up Combine subscribers + for cancellable in cancellables { + cancellable.cancel() + } + cancellables.removeAll() + } + + + + /** + * Delete attachments that have been marked as archived + */ + func deleteArchivedAttachments() async throws -> Bool { + return try await attachmentsService.deleteArchivedAttachments { pendingDelete in + for attachment in pendingDelete { + guard let localUri = attachment.localUri else { + continue + } + + if (try await false == self.localStorage.fileExists(filePath: localUri)) { + continue + } + + try await self.localStorage.deleteFile(filePath: localUri) + } + } + } + + /** + * Handle downloading, uploading or deleting of attachments + */ + private func handleSync(attachments: [Attachment]) async throws { + var updatedAttachments = [Attachment]() + + do { + for attachment in attachments { + let state = AttachmentState(rawValue: attachment.state) + + switch state { + case .queuedDownload: +// logger.info("Downloading \(attachment.filename)") + let updated = try await downloadAttachment(attachment: attachment) + updatedAttachments.append(updated) + + case .queuedUpload: +// logger.info("Uploading \(attachment.filename)") + let updated = try await uploadAttachment(attachment: attachment) + updatedAttachments.append(updated) + + case .queuedDelete: +// logger.info("Deleting \(attachment.filename)") + let updated = try await deleteAttachment(attachment: attachment) + updatedAttachments.append(updated) + + default: + break + } + } + + // Update the state of processed attachments + try await attachmentsService.saveAttachments(attachments: updatedAttachments) + } catch { + // We retry on the next invocation whenever there are errors at this level +// logger.error("Error during sync: \(error.localizedDescription)") + throw error + } + } + + /** + * Upload attachment from local storage to remote storage. + */ + private func uploadAttachment(attachment: Attachment) async throws -> Attachment { + do { + guard let localUri = attachment.localUri else { + throw PowerSyncError.attachmentError("No localUri for attachment \(attachment.id)") + } + + let fileData = try await localStorage.readFile(filePath: localUri) + try await remoteStorage.uploadFile(fileData: fileData, attachment: attachment) + +// logger.info("Uploaded attachment \"\(attachment.id)\" to Cloud Storage") + return attachment.with(state: AttachmentState.synced.rawValue, hasSynced: 1) + } catch { +// logger.error("Upload attachment error for attachment \(attachment.id): \(error.localizedDescription)") + + if let errorHandler = errorHandler { + let shouldRetry = await errorHandler.onUploadError(attachment: attachment, error: error) + if !shouldRetry { +// logger.info("Attachment with ID \(attachment.id) has been archived") + return attachment.with(state: AttachmentState.archived.rawValue) + } + } + + // Retry the upload (same state) + return attachment + } + } + + /** + * Download attachment from remote storage and save it to local storage. + * Returns the updated state of the attachment. + */ + private func downloadAttachment(attachment: Attachment) async throws -> Attachment { + do { + // When downloading an attachment we take the filename and resolve + // the local_uri where the file will be stored + let attachmentPath = await getLocalUri(attachment.filename) + + let fileData = try await remoteStorage.downloadFile(attachment: attachment) + _ = try await localStorage.saveFile(filePath: attachmentPath, data: fileData) + +// logger.info("Downloaded file \"\(attachment.id)\"") + + // The attachment has been downloaded locally + return attachment.with( + state: AttachmentState.synced.rawValue, + hasSynced: 1, + localUri: attachmentPath, + ) + } catch { + if let errorHandler = errorHandler { + let shouldRetry = await errorHandler.onDownloadError(attachment: attachment, error: error) + if !shouldRetry { +// logger.info("Attachment with ID \(attachment.id) has been archived") + return attachment.with(state: AttachmentState.archived.rawValue) + } + } + +// logger.error("Download attachment error for attachment \(attachment.id): \(error.localizedDescription)") + // Return the same state, this will cause a retry + return attachment + } + } + + /** + * Delete attachment from remote, local storage and then remove it from the queue. + */ + private func deleteAttachment(attachment: Attachment) async throws -> Attachment { + do { + try await remoteStorage.deleteFile(attachment: attachment) + + if let localUri = attachment.localUri { + try await localStorage.deleteFile(filePath: localUri) + } + + return attachment.with(state: AttachmentState.archived.rawValue) + } catch { + if let errorHandler = errorHandler { + let shouldRetry = await errorHandler.onDeleteError(attachment: attachment, error: error) + if !shouldRetry { +// logger.info("Attachment with ID \(attachment.id) has been archived") + return attachment.with(state: AttachmentState.archived.rawValue) + } + } + + // We'll retry this +// logger.error("Error deleting attachment: \(error.localizedDescription)") + return attachment + } + } +} From 282ed061f2d233cc3545d4227d250b5dcaea9d4f Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Sun, 6 Apr 2025 17:01:24 +0200 Subject: [PATCH 02/36] wip: Add unit tests --- .../xcshareddata/xcschemes/PowerSync.xcscheme | 79 +++++++ Package.resolved | 9 + Package.swift | 3 +- .../attachments/AttachmentsTable.swift | 11 + .../Kotlin/AttachmentTests.swift | 209 ++++++++++++++++++ .../KotlinPowerSyncDatabaseImplTests.swift | 4 + 6 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/PowerSync.xcscheme create mode 100644 Sources/PowerSync/attachments/AttachmentsTable.swift create mode 100644 Tests/PowerSyncTests/Kotlin/AttachmentTests.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/PowerSync.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/PowerSync.xcscheme new file mode 100644 index 0000000..196f39c --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/PowerSync.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.resolved b/Package.resolved index 32c002c..c1c16d3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "powersync-kotlin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/powersync-ja/powersync-kotlin.git", + "state" : { + "revision" : "443df078f4b9352de137000b993d564d4ab019b7", + "version" : "1.0.0-BETA28.0" + } + }, { "identity" : "powersync-sqlite-core-swift", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 3ac3562..e870748 100644 --- a/Package.swift +++ b/Package.swift @@ -16,8 +16,7 @@ let package = Package( targets: ["PowerSync"]), ], dependencies: [ - .package(path: "/Users/stevenontong/Documents/platform_code/powersync/powersync-kotlin"), - // .package(url: "https://github.com/powersync-ja/powersync-kotlin.git", exact: "1.0.0-BETA28.0"), + .package(url: "https://github.com/powersync-ja/powersync-kotlin.git", exact: "1.0.0-BETA28.0"), .package(url: "https://github.com/powersync-ja/powersync-sqlite-core-swift.git", "0.3.12"..<"0.4.0") ], targets: [ diff --git a/Sources/PowerSync/attachments/AttachmentsTable.swift b/Sources/PowerSync/attachments/AttachmentsTable.swift new file mode 100644 index 0000000..a1fe4a7 --- /dev/null +++ b/Sources/PowerSync/attachments/AttachmentsTable.swift @@ -0,0 +1,11 @@ +func createAttachmentsTable(name: String) -> Table { + return Table(name: name, columns: [ + .integer("timestamp"), + .integer("state"), + .text("filename"), + .integer("has_synced"), + .text("local_uri"), + .text("media_type"), + .integer("size") + ]) +} diff --git a/Tests/PowerSyncTests/Kotlin/AttachmentTests.swift b/Tests/PowerSyncTests/Kotlin/AttachmentTests.swift new file mode 100644 index 0000000..cf94b78 --- /dev/null +++ b/Tests/PowerSyncTests/Kotlin/AttachmentTests.swift @@ -0,0 +1,209 @@ + +@testable import PowerSync +import XCTest + +final class AttachmentTests: XCTestCase { + private var database: PowerSyncDatabaseProtocol! + private var schema: Schema! + + override func setUp() async throws { + try await super.setUp() + schema = Schema(tables: [ + Table(name: "users", columns: [ + .text("name"), + .text("email"), + .text("photo_id") + ]), + createAttachmentsTable(name: "attachments") + ]) + + database = PowerSyncDatabase( + schema: schema, + dbFilename: ":memory:" + ) + try await database.disconnectAndClear() + } + + override func tearDown() async throws { + try await database.disconnectAndClear() + database = nil + try await super.tearDown() + } + + func testAttachmentDownload() async throws { + let queue = AttachmentQueue( + db: database, + remoteStorage: { + struct MockRemoteStorage: RemoteStorageAdapter { + func uploadFile( + fileData: Data, + attachment: Attachment + ) async throws {} + + /** + * Download a file from remote storage + */ + func downloadFile(attachment: Attachment) async throws -> Data { + return Data([1,2,3]) + } + + /** + * Delete a file from remote storage + */ + func deleteFile(attachment: Attachment) async throws {} + + } + + return MockRemoteStorage() + }(), + attachmentDirectory: NSTemporaryDirectory(), + watchedAttachments: try database.watch(options: WatchOptions( + sql: "SELECT photo_id FROM users WHERE photo_id IS NOT NULL", + mapper: { cursor in WatchedAttachmentItem( + id: try cursor.getString(name: "photo_id"), + fileExtension: "jpg" + )} + )) + ) + + try await queue.startSync() + + // Create a user which has a photo_id associated. + // This will be treated as a download since no attachment record was created. + // saveFile creates the attachment record before the updates are made. + _ = try await database.execute( + sql: "INSERT INTO users (id, name, email, photo_id) VALUES (uuid(), 'steven', 'steven@example.com', uuid())", + parameters: [] + ) + + var attachmentsWatch = try database.watch( + options: WatchOptions( + sql: "SELECT * FROM attachments", + mapper: {cursor in try Attachment.fromCursor(cursor)} + )).makeAsyncIterator() + + var attachmentRecord = try await waitForMatch( + iterator: attachmentsWatch, + where: {results in results.first?.state == AttachmentState.synced.rawValue}, + timeout: 5 + ).first + + // The file should exist + let localData = try await queue.localStorage.readFile(filePath: attachmentRecord!.localUri!) + XCTAssertEqual(localData.count, 3) + + try await queue.clearQueue() + try await queue.close() + } + + func testAttachmentUpload() async throws { + + class MockRemoteStorage: RemoteStorageAdapter { + public var uploadCalled = false + + func uploadFile( + fileData: Data, + attachment: Attachment + ) async throws { + self.uploadCalled = true + } + + /** + * Download a file from remote storage + */ + func downloadFile(attachment: Attachment) async throws -> Data { + return Data([1,2,3]) + } + + /** + * Delete a file from remote storage + */ + func deleteFile(attachment: Attachment) async throws {} + + } + + + + let mockedRemote = MockRemoteStorage() + + let queue = AttachmentQueue( + db: database, + remoteStorage: mockedRemote, + attachmentDirectory: NSTemporaryDirectory(), + watchedAttachments: try database.watch(options: WatchOptions( + sql: "SELECT photo_id FROM users WHERE photo_id IS NOT NULL", + mapper: { cursor in WatchedAttachmentItem( + id: try cursor.getString(name: "photo_id"), + fileExtension: "jpg" + )} + )) + ) + + try await queue.startSync() + + let attachmentsWatch = try database.watch( + options: WatchOptions( + sql: "SELECT * FROM attachments", + mapper: {cursor in try Attachment.fromCursor(cursor)} + )).makeAsyncIterator() + + _ = try await queue.saveFile( + data: Data([3,4,5]), + mediaType: "image/jpg", + fileExtension: "jpg") {tx, attachment in + _ = try tx.execute( + sql: "INSERT INTO users (id, name, email, photo_id) VALUES (uuid(), 'john', 'j@j.com', ?)", + parameters: [attachment.id] + ) + } + + _ = try await waitForMatch( + iterator: attachmentsWatch, + where: {results in results.first?.state == AttachmentState.synced.rawValue}, + timeout: 5 + ).first + + // Upload should have been called + XCTAssertTrue(mockedRemote.uploadCalled) + + try await queue.clearQueue() + try await queue.close() + } +} + + +enum WaitForMatchError: Error { + case timeout +} + +func waitForMatch( + iterator: AsyncThrowingStream.Iterator, + where predicate: @escaping (T) -> Bool, + timeout: TimeInterval +) async throws -> T { + var localIterator = iterator + let timeoutNanoseconds = UInt64(timeout * 1_000_000_000) + + return try await withThrowingTaskGroup(of: T.self) { group in + // Task to wait for a matching value + group.addTask { + while let value = try await localIterator.next() { + if predicate(value) { + return value + } + } + throw WaitForMatchError.timeout // stream ended before match + } + + // Task to enforce timeout + group.addTask { + try await Task.sleep(nanoseconds: timeoutNanoseconds) + throw WaitForMatchError.timeout + } + + // First one to succeed or fail + let result = try await group.next() + group.cancelAll() + return result! + } +} diff --git a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift index 3c645c5..20f9ed4 100644 --- a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift +++ b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift @@ -11,7 +11,9 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { Table(name: "users", columns: [ .text("name"), .text("email"), + .text("photo_id") ]), + createAttachmentsTable(name: "attachments") ]) database = KotlinPowerSyncDatabaseImpl( @@ -63,6 +65,8 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { XCTAssertEqual(user.1, "Test User") XCTAssertEqual(user.2, "test@example.com") } + + func testGetError() async throws { do { From 18bcd4c6bd9bf88d5ba2a849a0d4081b2eff6f04 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 8 Apr 2025 13:48:33 +0200 Subject: [PATCH 03/36] cleanup docs --- .../PowerSync/attachments/Attachment.swift | 100 +++++--- .../attachments/AttachmentQueue.swift | 224 ++++++----------- .../attachments/AttachmentsTable.swift | 3 +- .../PowerSync/attachments/LocalStorage.swift | 88 ++++--- .../PowerSync/attachments/RemoteStorage.swift | 29 ++- .../attachments/SyncErrorHandler.swift | 67 ++--- .../attachments/SyncingService.swift | 228 +++++++----------- .../Kotlin/AttachmentTests.swift | 8 +- 8 files changed, 362 insertions(+), 385 deletions(-) diff --git a/Sources/PowerSync/attachments/Attachment.swift b/Sources/PowerSync/attachments/Attachment.swift index b8bd3bd..66d00a6 100644 --- a/Sources/PowerSync/attachments/Attachment.swift +++ b/Sources/PowerSync/attachments/Attachment.swift @@ -1,31 +1,48 @@ -/** - * Enum for the attachment state - */ +/// Enum representing the state of an attachment public enum AttachmentState: Int { + /// The attachment is queued for download case queuedDownload + /// The attachment is queued for upload case queuedUpload + /// The attachment is queued for deletion case queuedDelete + /// The attachment is fully synced case synced + /// The attachment is archived case archived } -/** - * Struct representing an attachment - */ +/// Struct representing an attachment public struct Attachment { + /// Unique identifier for the attachment let id: String + + /// Timestamp for the last record update let timestamp: Int + + /// Attachment filename, e.g. `[id].jpg` let filename: String + + /// Current attachment state, represented by the raw value of `AttachmentState` let state: Int + + /// Local URI pointing to the attachment file let localUri: String? + + /// Attachment media type (usually a MIME type) let mediaType: String? + + /// Attachment byte size let size: Int64? - /** - * Specifies if the attachment has been synced locally before. This is particularly useful - * for restoring archived attachments in edge cases. - */ + + /// Specifies if the attachment has been synced locally before. + /// This is particularly useful for restoring archived attachments in edge cases. let hasSynced: Int? - + + /// Extra attachment metadata + let metaData: String? + + /// Initializes a new `Attachment` instance public init( id: String, filename: String, @@ -35,6 +52,7 @@ public struct Attachment { localUri: String? = nil, mediaType: String? = nil, size: Int64? = nil, + metaData: String? = nil ) { self.id = id self.timestamp = timestamp @@ -44,22 +62,50 @@ public struct Attachment { self.mediaType = mediaType self.size = size self.hasSynced = hasSynced + self.metaData = metaData } - - func with(filename: String? = nil, state: Int? = nil, hasSynced: Int? = nil, localUri: String? = nil, mediaType: String? = nil, size: Int64? = nil ) -> Attachment { - return Attachment( - id: self.id, - filename: self.filename, - state: state ?? self.state, - hasSynced: hasSynced ?? self.hasSynced, - localUri: localUri ?? self.localUri, - mediaType: mediaType ?? self.mediaType, - size: size ?? self.size, - ) - } - - public static func fromCursor(_ cursor: SqlCursor) throws -> Attachment { - return Attachment( + + /// Returns a new `Attachment` instance with the option to override specific fields. + /// + /// - Parameters: + /// - filename: Optional new filename. + /// - state: Optional new state. + /// - timestamp: Optional new timestamp. + /// - hasSynced: Optional new `hasSynced` flag. + /// - localUri: Optional new local URI. + /// - mediaType: Optional new media type. + /// - size: Optional new size. + /// - metaData: Optional new metadata. + /// - Returns: A new `Attachment` with updated values. + func with( + filename: String? = nil, + state: Int? = nil, + timestamp: Int = 0, + hasSynced: Int? = 0, + localUri: String? = nil, + mediaType: String? = nil, + size: Int64? = nil, + metaData: String? = nil + ) -> Attachment { + return Attachment( + id: self.id, + filename: self.filename, + state: state ?? self.state, + hasSynced: hasSynced ?? self.hasSynced, + localUri: localUri ?? self.localUri, + mediaType: mediaType ?? self.mediaType, + size: size ?? self.size, + metaData: metaData ?? self.metaData + ) + } + + /// Constructs an `Attachment` from a `SqlCursor`. + /// + /// - Parameter cursor: The `SqlCursor` containing the attachment data. + /// - Throws: If required fields are missing or of incorrect type. + /// - Returns: A fully constructed `Attachment` instance. + public static func fromCursor(_ cursor: SqlCursor) throws -> Attachment { + return Attachment( id: try cursor.getString(name: "id"), filename: try cursor.getString(name: "filename"), state: try cursor.getLong(name: "state"), @@ -68,7 +114,7 @@ public struct Attachment { localUri: try cursor.getStringOptional(name: "local_uri"), mediaType: try cursor.getStringOptional(name: "media_type"), size: try cursor.getLongOptional(name: "size")?.int64Value, + metaData: try cursor.getStringOptional(name: "meta_data") ) } } - diff --git a/Sources/PowerSync/attachments/AttachmentQueue.swift b/Sources/PowerSync/attachments/AttachmentQueue.swift index 5e38ea4..0a35589 100644 --- a/Sources/PowerSync/attachments/AttachmentQueue.swift +++ b/Sources/PowerSync/attachments/AttachmentQueue.swift @@ -5,108 +5,83 @@ import OSLog //TODO should not need this import PowerSyncKotlin -/** - * A watched attachment record item. - * This is usually returned from watching all relevant attachment IDs. - */ +/// A watched attachment record item. +/// This is usually returned from watching all relevant attachment IDs. public struct WatchedAttachmentItem { - /** - * Id for the attachment record - */ + /// Id for the attachment record public let id: String - /** - * File extension used to determine an internal filename for storage if no `filename` is provided - */ + /// File extension used to determine an internal filename for storage if no `filename` is provided public let fileExtension: String? - /** - * Filename to store the attachment with - */ + /// Filename to store the attachment with public let filename: String? - public init(id: String, fileExtension: String? = nil, filename: String? = nil) { + /// Metadata for the attachment (optional) + public let metaData: String? + + /// Initializes a new `WatchedAttachmentItem` + /// - Parameters: + /// - id: Attachment record ID + /// - fileExtension: Optional file extension + /// - filename: Optional filename + /// - metaData: Optional metadata + public init( + id: String, + fileExtension: String? = nil, + filename: String? = nil, + metaData: String? = nil + ) { self.id = id self.fileExtension = fileExtension self.filename = filename + self.metaData = metaData precondition(fileExtension != nil || filename != nil, "Either fileExtension or filename must be provided.") } } -/** - * Class used to implement the attachment queue - * Requires a PowerSyncDatabase, an implementation of - * RemoteStorageAdapter and an attachment directory name which will - * determine which folder attachments are stored into. - */ +/// Class used to implement the attachment queue +/// Requires a PowerSyncDatabase, a RemoteStorageAdapter implementation, and a directory name for attachments. public actor AttachmentQueue { + /// Default name of the attachments table public static let DEFAULT_TABLE_NAME = "attachments" - public static let DEFAULT_ATTACHMENTS_DIRECTORY_NAME = "attachments" - /** - * PowerSync database client - */ + /// PowerSync database client public let db: PowerSyncDatabaseProtocol - /** - * Adapter which interfaces with the remote storage backend - */ + /// Remote storage adapter public let remoteStorage: RemoteStorageAdapter - /** - * Directory name where attachment files will be written to disk. - * This will be created if it does not exist - */ - private let attachmentDirectory: String + /// Directory name for attachments + private let attachmentsDirectory: String - /** - * A publisher for the current state of local attachments - */ + /// Stream of watched attachments private let watchedAttachments: AsyncThrowingStream<[WatchedAttachmentItem], Error> - /** - * Provides access to local filesystem storage methods - */ + /// Local file system adapter public let localStorage: LocalStorageAdapter - /** - * SQLite table where attachment state will be recorded - */ + /// Attachments table name in SQLite private let attachmentsQueueTableName: String - /** - * Attachment operation error handler. This specified if failed attachment operations - * should be retried. - */ + /// Optional sync error handler private let errorHandler: SyncErrorHandler? - /** - * Periodic interval to trigger attachment sync operations - */ + /// Interval between periodic syncs private let syncInterval: TimeInterval - /** - * Archived attachments can be used as a cache which can be restored if an attachment id - * reappears after being removed. This parameter defines how many archived records are retained. - * Records are deleted once the number of items exceeds this value. - */ + /// Limit on number of archived attachments private let archivedCacheLimit: Int64 - /** - * Throttles remote sync operations triggering - */ + /// Duration for throttling sync operations private let syncThrottleDuration: TimeInterval - /** - * Creates a list of subdirectories in the attachmentDirectory - */ + /// Subdirectories to be created in attachments directory private let subdirectories: [String]? - /** - * Should attachments be downloaded - */ + /// Whether to allow downloading of attachments private let downloadAttachments: Bool /** @@ -114,25 +89,16 @@ public actor AttachmentQueue { */ // public let logger: Logger - /** - * Service which provides access to attachment records. - * Use this to: - * - Query all current attachment records - * - Create new attachment records for upload/download - */ + /// Attachment service for interacting with attachment records public let attachmentsService: AttachmentService private var syncStatusTask: Task? - private let mutex = NSLock() private var cancellables = Set() + /// Indicates whether the queue has been closed public private(set) var closed: Bool = false - /** - * Syncing service for this attachment queue. - * This processes attachment records and performs relevant upload, download and delete - * operations. - */ + /// Syncing service instance private(set) lazy var syncingService: SyncingService = { return SyncingService( remoteStorage: self.remoteStorage, @@ -147,10 +113,12 @@ public actor AttachmentQueue { ) }() + /// Initializes the attachment queue + /// - Parameters match the stored properties public init( db: PowerSyncDatabaseProtocol, remoteStorage: RemoteStorageAdapter, - attachmentDirectory: String, + attachmentsDirectory: String, watchedAttachments: AsyncThrowingStream<[WatchedAttachmentItem], Error>, localStorage: LocalStorageAdapter = FileManagerStorageAdapter(), attachmentsQueueTableName: String = DEFAULT_TABLE_NAME, @@ -164,7 +132,7 @@ public actor AttachmentQueue { ) { self.db = db self.remoteStorage = remoteStorage - self.attachmentDirectory = attachmentDirectory + self.attachmentsDirectory = attachmentsDirectory self.watchedAttachments = watchedAttachments self.localStorage = localStorage self.attachmentsQueueTableName = attachmentsQueueTableName @@ -184,23 +152,18 @@ public actor AttachmentQueue { ) } - /** - * Initialize the attachment queue by - * 1. Creating attachments directory - * 2. Adding watches for uploads, downloads, and deletes - * 3. Adding trigger to run uploads, downloads, and deletes when device is online after being offline - */ + /// Starts the attachment sync process public func startSync() async throws { if closed { throw NSError(domain: "AttachmentError", code: 4, userInfo: [NSLocalizedDescriptionKey: "Attachment queue has been closed"]) } // Ensure the directory where attachments are downloaded exists - try await localStorage.makeDir(path: attachmentDirectory) + try await localStorage.makeDir(path: attachmentsDirectory) if let subdirectories = subdirectories { for subdirectory in subdirectories { - let path = URL(fileURLWithPath: attachmentDirectory).appendingPathComponent(subdirectory).path + let path = URL(fileURLWithPath: attachmentsDirectory).appendingPathComponent(subdirectory).path try await localStorage.makeDir(path: path) } } @@ -239,6 +202,7 @@ public actor AttachmentQueue { } } + /// Closes the attachment queue and cancels all sync tasks public func close() async throws { if closed { return @@ -249,12 +213,11 @@ public actor AttachmentQueue { closed = true } - /** - * Resolves the filename for new attachment items. - * A new attachment from watchedAttachments might not include a filename. - * Concatenates the attachment ID and extension by default. - * This method can be overridden for custom behavior. - */ + /// Resolves the filename for a new attachment + /// - Parameters: + /// - attachmentId: Attachment ID + /// - fileExtension: File extension + /// - Returns: Resolved filename public func resolveNewAttachmentFilename( attachmentId: String, fileExtension: String? @@ -262,20 +225,8 @@ public actor AttachmentQueue { return "\(attachmentId).\(fileExtension ?? "")" } - /** - * Processes attachment items returned from watchedAttachments. - * The default implementation asserts the items returned from watchedAttachments as the definitive - * state for local attachments. - * - * Records currently in the attachment queue which are not present in the items are deleted from - * the queue. - * - * Received items which are not currently in the attachment queue are assumed scheduled for - * download. This requires that locally created attachments should be created with saveFile - * before assigning the attachment ID to the relevant watched tables. - * - * This method can be overridden for custom behavior. - */ + /// Processes watched attachment items and updates sync state + /// - Parameter items: List of watched attachment items public func processWatchedAttachments(items: [WatchedAttachmentItem]) async throws { // Need to get all the attachments which are tracked in the DB. // We might need to restore an archived attachment. @@ -314,11 +265,9 @@ public actor AttachmentQueue { existingQueueItem!.with(state: AttachmentState.synced.rawValue) ) } else { - /** - * The localURI should be set if the record was meant to be downloaded - * and has been synced. If it's missing and hasSynced is false then - * it must be an upload operation - */ + // The localURI should be set if the record was meant to be downloaded + // and has been synced. If it's missing and hasSynced is false then + // it must be an upload operation let newState = existingQueueItem!.localUri == nil ? AttachmentState.queuedDownload.rawValue : AttachmentState.queuedUpload.rawValue @@ -347,23 +296,18 @@ public actor AttachmentQueue { } } - /** - * A function which creates a new attachment locally. This new attachment is queued for upload - * after creation. - * - * The filename is resolved using resolveNewAttachmentFilename. - * - * A updateHook is provided which should be used when assigning relationships to the newly - * created attachment. This hook is executed in the same writeTransaction which creates the - * attachment record. - * - * This method can be overridden for custom behavior. - */ + /// Saves a new file and schedules it for upload + /// - Parameters: + /// - data: File data + /// - mediaType: MIME type + /// - fileExtension: File extension + /// - updateHook: Hook to assign attachment relationships in the same transaction + /// - Returns: The created attachment public func saveFile( data: Data, mediaType: String, fileExtension: String?, - updateHook: ((PowerSyncTransaction, Attachment) throws -> Void)? = nil + updateHook: @escaping (PowerSyncTransaction, Attachment) throws -> Void ) async throws -> Attachment { let id = try await db.get(sql: "SELECT uuid() as id", parameters: [], mapper: { cursor in try cursor.getString(name: "id") }) @@ -387,29 +331,26 @@ public actor AttachmentQueue { ) // Allow consumers to set relationships to this attachment id - try updateHook?(tx, attachment) + try updateHook(tx, attachment) return try self.attachmentsService.upsertAttachment(attachment, context: tx) } } - /** - * A function which creates an attachment delete operation locally. This operation is queued - * for delete. - * The default implementation assumes the attachment record already exists locally. An exception - * is thrown if the record does not exist locally. - * This method can be overridden for custom behavior. - */ + /// Queues a file for deletion + /// - Parameters: + /// - attachmentId: ID of the attachment to delete + /// - updateHook: Hook to perform additional DB updates in the same transaction public func deleteFile( attachmentId: String, - updateHook: ((ConnectionContext, Attachment) throws -> Void)? = nil + updateHook: @escaping (ConnectionContext, Attachment) throws -> Void ) async throws -> Attachment { guard let attachment = try await attachmentsService.getAttachment(id: attachmentId) else { throw NSError(domain: "AttachmentError", code: 5, userInfo: [NSLocalizedDescriptionKey: "Attachment record with id \(attachmentId) was not found."]) } return try await db.writeTransaction { tx in - try updateHook?(tx, attachment) + try updateHook(tx, attachment) let updatedAttachment = Attachment( id: attachment.id, @@ -425,17 +366,14 @@ public actor AttachmentQueue { } } - /** - * Return user's storage directory with the attachmentPath used to load the file. - * Example: filePath: "attachment-1.jpg" returns "/path/to/Documents/attachments/attachment-1.jpg" - */ + /// Returns the local URI where a file is stored based on filename + /// - Parameter filename: The name of the file + /// - Returns: The file path public func getLocalUri(_ filename: String) -> String { - return URL(fileURLWithPath: attachmentDirectory).appendingPathComponent(filename).path + return URL(fileURLWithPath: attachmentsDirectory).appendingPathComponent(filename).path } - /** - * Removes all archived items - */ + /// Removes all archived items public func expireCache() async throws { var done = false repeat { @@ -443,12 +381,10 @@ public actor AttachmentQueue { } while !done } - /** - * Clears the attachment queue and deletes all attachment files - */ + /// Clears the attachment queue and deletes all attachment files public func clearQueue() async throws { try await attachmentsService.clearQueue() // Remove the attachments directory - try await localStorage.rmDir(path: attachmentDirectory) + try await localStorage.rmDir(path: attachmentsDirectory) } } diff --git a/Sources/PowerSync/attachments/AttachmentsTable.swift b/Sources/PowerSync/attachments/AttachmentsTable.swift index a1fe4a7..8214b95 100644 --- a/Sources/PowerSync/attachments/AttachmentsTable.swift +++ b/Sources/PowerSync/attachments/AttachmentsTable.swift @@ -6,6 +6,7 @@ func createAttachmentsTable(name: String) -> Table { .integer("has_synced"), .text("local_uri"), .text("media_type"), - .integer("size") + .integer("size"), + .text("meta_data") ]) } diff --git a/Sources/PowerSync/attachments/LocalStorage.swift b/Sources/PowerSync/attachments/LocalStorage.swift index ccfe839..daf8b65 100644 --- a/Sources/PowerSync/attachments/LocalStorage.swift +++ b/Sources/PowerSync/attachments/LocalStorage.swift @@ -1,72 +1,96 @@ import Foundation -/** - * Error type for PowerSync operations - */ +/// Error type for PowerSync operations public enum PowerSyncError: Error { + /// A general error with an associated message case generalError(String) + + /// Indicates that a file was not found at the given path case fileNotFound(String) + + /// An I/O error occurred case ioError(Error) + + /// The given file or directory path was invalid case invalidPath(String) + + /// An error related to attachment handling case attachmentError(String) } -/** - * Storage adapter for local storage - */ +/// Protocol defining an adapter interface for local file storage public protocol LocalStorageAdapter { - /** - * Saves data to a file at the specified path. - * @returns the bytesize of the file - */ + + /// Saves data to a file at the specified path. + /// + /// - Parameters: + /// - filePath: The full path where the file should be saved. + /// - data: The binary data to save. + /// - Returns: The byte size of the saved file. + /// - Throws: `PowerSyncError` if saving fails. func saveFile( filePath: String, data: Data ) async throws -> Int64 - /** - * Reads a file from the specified path. - */ + /// Reads a file from the specified path. + /// + /// - Parameters: + /// - filePath: The full path to the file. + /// - mediaType: An optional media type (MIME type) to help determine how to handle the file. + /// - Returns: The contents of the file as `Data`. + /// - Throws: `PowerSyncError` if reading fails or the file doesn't exist. func readFile( filePath: String, mediaType: String? ) async throws -> Data - /** - * Deletes a file at the specified path. - */ + /// Deletes a file at the specified path. + /// + /// - Parameter filePath: The full path to the file to delete. + /// - Throws: `PowerSyncError` if deletion fails or file doesn't exist. func deleteFile(filePath: String) async throws - /** - * Checks if a file exists at the specified path. - */ + /// Checks if a file exists at the specified path. + /// + /// - Parameter filePath: The path to the file. + /// - Returns: `true` if the file exists, `false` otherwise. + /// - Throws: `PowerSyncError` if checking fails. func fileExists(filePath: String) async throws -> Bool - /** - * Creates a directory at the specified path. - */ + /// Creates a directory at the specified path. + /// + /// - Parameter path: The full path to the directory. + /// - Throws: `PowerSyncError` if creation fails. func makeDir(path: String) async throws - /** - * Removes a directory at the specified path. - */ + /// Removes a directory at the specified path. + /// + /// - Parameter path: The full path to the directory. + /// - Throws: `PowerSyncError` if removal fails. func rmDir(path: String) async throws - /** - * Copies a file from source path to target path. - */ + /// Copies a file from the source path to the target path. + /// + /// - Parameters: + /// - sourcePath: The original file path. + /// - targetPath: The destination file path. + /// - Throws: `PowerSyncError` if the copy operation fails. func copyFile( sourcePath: String, targetPath: String ) async throws } -/** - * Extension providing default parameter for readFile - */ +/// Extension providing a default implementation of `readFile` without a media type public extension LocalStorageAdapter { + + /// Reads a file from the specified path without specifying a media type. + /// + /// - Parameter filePath: The full path to the file. + /// - Returns: The contents of the file as `Data`. + /// - Throws: `PowerSyncError` if reading fails. func readFile(filePath: String) async throws -> Data { return try await readFile(filePath: filePath, mediaType: nil) } } - diff --git a/Sources/PowerSync/attachments/RemoteStorage.swift b/Sources/PowerSync/attachments/RemoteStorage.swift index d75f104..27ba631 100644 --- a/Sources/PowerSync/attachments/RemoteStorage.swift +++ b/Sources/PowerSync/attachments/RemoteStorage.swift @@ -1,24 +1,29 @@ import Foundation -/** - * Adapter for interfacing with remote attachment storage. - */ +/// Adapter for interfacing with remote attachment storage. public protocol RemoteStorageAdapter { - /** - * Upload a file to remote storage - */ + + /// Uploads a file to remote storage. + /// + /// - Parameters: + /// - fileData: The binary content of the file to upload. + /// - attachment: The associated `Attachment` metadata describing the file. + /// - Throws: An error if the upload fails. func uploadFile( fileData: Data, attachment: Attachment ) async throws - /** - * Download a file from remote storage - */ + /// Downloads a file from remote storage. + /// + /// - Parameter attachment: The `Attachment` describing the file to download. + /// - Returns: The binary data of the downloaded file. + /// - Throws: An error if the download fails or the file is not found. func downloadFile(attachment: Attachment) async throws -> Data - /** - * Delete a file from remote storage - */ + /// Deletes a file from remote storage. + /// + /// - Parameter attachment: The `Attachment` describing the file to delete. + /// - Throws: An error if the deletion fails or the file does not exist. func deleteFile(attachment: Attachment) async throws } diff --git a/Sources/PowerSync/attachments/SyncErrorHandler.swift b/Sources/PowerSync/attachments/SyncErrorHandler.swift index 3624552..268d957 100644 --- a/Sources/PowerSync/attachments/SyncErrorHandler.swift +++ b/Sources/PowerSync/attachments/SyncErrorHandler.swift @@ -1,54 +1,65 @@ import Foundation -/** - * Handles attachment operation errors. - * The handlers here specify if the corresponding operations should be retried. - * Attachment records are archived if an operation failed and should not be retried. - */ +/// Handles attachment operation errors. +/// +/// The handlers defined in this protocol specify whether corresponding attachment +/// operations (download, upload, delete) should be retried upon failure. +/// +/// If an operation fails and should not be retried, the attachment record is archived. public protocol SyncErrorHandler { - /** - * @returns if the provided attachment download operation should be retried - */ + + /// Handles a download error for a specific attachment. + /// + /// - Parameters: + /// - attachment: The `Attachment` that failed to download. + /// - error: The error encountered during the download operation. + /// - Returns: `true` if the operation should be retried, `false` if it should be archived. func onDownloadError( attachment: Attachment, error: Error ) async -> Bool - - /** - * @returns if the provided attachment upload operation should be retried - */ + + /// Handles an upload error for a specific attachment. + /// + /// - Parameters: + /// - attachment: The `Attachment` that failed to upload. + /// - error: The error encountered during the upload operation. + /// - Returns: `true` if the operation should be retried, `false` if it should be archived. func onUploadError( attachment: Attachment, error: Error ) async -> Bool - - /** - * @returns if the provided attachment delete operation should be retried - */ + + /// Handles a delete error for a specific attachment. + /// + /// - Parameters: + /// - attachment: The `Attachment` that failed to be deleted. + /// - error: The error encountered during the delete operation. + /// - Returns: `true` if the operation should be retried, `false` if it should be archived. func onDeleteError( attachment: Attachment, error: Error ) async -> Bool } -/** - * Default implementation of SyncErrorHandler - */ +/// Default implementation of `SyncErrorHandler`. +/// +/// By default, all operations return `false`, indicating no retry. public class DefaultSyncErrorHandler: SyncErrorHandler { public init() {} - + public func onDownloadError(attachment: Attachment, error: Error) async -> Bool { - // Default implementation could log the error and determine retry based on error type - return false // Don't retry by default + // Default: do not retry failed downloads + return false } - + public func onUploadError(attachment: Attachment, error: Error) async -> Bool { - // Default implementation could log the error and determine retry based on error type - return false // Don't retry by default + // Default: do not retry failed uploads + return false } - + public func onDeleteError(attachment: Attachment, error: Error) async -> Bool { - // Default implementation could log the error and determine retry based on error type - return false // Don't retry by default + // Default: do not retry failed deletions + return false } } diff --git a/Sources/PowerSync/attachments/SyncingService.swift b/Sources/PowerSync/attachments/SyncingService.swift index 3fba8b4..ace2bd7 100644 --- a/Sources/PowerSync/attachments/SyncingService.swift +++ b/Sources/PowerSync/attachments/SyncingService.swift @@ -1,29 +1,38 @@ import Foundation import Combine -/** - * Service used to sync attachments between local and remote storage - */ +/// A service that synchronizes attachments between local and remote storage. +/// +/// This actor watches for changes to active attachments and performs queued +/// download, upload, and delete operations. Syncs can be triggered manually, +/// periodically, or based on database changes. actor SyncingService { private let remoteStorage: RemoteStorageAdapter private let localStorage: LocalStorageAdapter private let attachmentsService: AttachmentService private let getLocalUri: (String) async -> String private let errorHandler: SyncErrorHandler? -// private let logger: Logger private let syncThrottle: TimeInterval private var cancellables = Set() private let syncTriggerSubject = PassthroughSubject() private var periodicSyncTimer: Timer? private var syncTask: Task? - + + /// Initializes a new instance of `SyncingService`. + /// + /// - Parameters: + /// - remoteStorage: Adapter for remote storage access. + /// - localStorage: Adapter for local storage access. + /// - attachmentsService: Service for querying and updating attachments. + /// - getLocalUri: Callback used to resolve a local path for saving downloaded attachments. + /// - errorHandler: Optional handler to determine if sync errors should be retried. + /// - syncThrottle: Throttle interval to control frequency of sync triggers. init( remoteStorage: RemoteStorageAdapter, localStorage: LocalStorageAdapter, attachmentsService: AttachmentService, getLocalUri: @escaping (String) async -> String, errorHandler: SyncErrorHandler? = nil, -// logger: Logger, syncThrottle: TimeInterval = 5.0 ) { self.remoteStorage = remoteStorage @@ -31,254 +40,199 @@ actor SyncingService { self.attachmentsService = attachmentsService self.getLocalUri = getLocalUri self.errorHandler = errorHandler -// self.logger = logger self.syncThrottle = syncThrottle - - // We use an actor for synchronisation. - // This needs to be executed in a non-isolated environment during init + Task { await self.setupSyncFlow() } } - - + + /// Sets up the main attachment syncing pipeline and starts watching for changes. private func setupSyncFlow() { - // Create a Task that will process sync events syncTask = Task { - // Create an AsyncStream from the syncTriggerSubject let syncTrigger = AsyncStream { continuation in let cancellable = syncTriggerSubject .throttle(for: .seconds(syncThrottle), scheduler: DispatchQueue.global(), latest: true) .sink { _ in continuation.yield(()) } - + continuation.onTermination = { _ in cancellable.cancel() } self.cancellables.insert(cancellable) } - - // Create a task that watches for active attachments + let watchTask = Task { for try await _ in try attachmentsService.watchActiveAttachments() { - // When an attachment changes, trigger a sync syncTriggerSubject.send(()) } } - - // Process sync triggers + for await _ in syncTrigger { guard !Task.isCancelled else { break } - + do { - // Process active attachments let attachments = try await attachmentsService.getActiveAttachments() try await handleSync(attachments: attachments) - - // Cleanup archived attachments _ = try await deleteArchivedAttachments() } catch { - if error is CancellationError { - break - } -// logger.error("Caught exception when processing attachments: \(error)") + if error is CancellationError { break } + // logger.error("Sync failure: \(error)") } } - - // Clean up the watch task when we're done + watchTask.cancel() } } - + + /// Starts periodic syncing of attachments. + /// + /// - Parameter period: The time interval in seconds between each sync. func startPeriodicSync(period: TimeInterval) async { - // Cancel existing timer if any if let timer = periodicSyncTimer { timer.invalidate() periodicSyncTimer = nil } - - // Create a new timer on the main actor and store the reference + periodicSyncTimer = Timer.scheduledTimer(withTimeInterval: period, repeats: true) { [weak self] _ in - guard let self = self else { return } - Task { - await self.triggerSync() - } - } - - // Trigger initial sync + guard let self = self else { return } + Task { await self.triggerSync() } + } + await triggerSync() } - + + /// Triggers a sync operation. Can be called manually. func triggerSync() async { - // This is safe to call from outside the actor syncTriggerSubject.send(()) } - + + /// Cleans up internal resources and cancels any ongoing syncing. func close() async { - // Cancel and clean up timer if let timer = periodicSyncTimer { timer.invalidate() periodicSyncTimer = nil } - - // Cancel the sync task + syncTask?.cancel() syncTask = nil - - // Clean up Combine subscribers + for cancellable in cancellables { cancellable.cancel() } cancellables.removeAll() } - - - /** - * Delete attachments that have been marked as archived - */ - func deleteArchivedAttachments() async throws -> Bool { + /// Deletes attachments marked as archived that exist on local storage. + /// + /// - Returns: `true` if any deletions occurred, `false` otherwise. + func deleteArchivedAttachments() async throws -> Bool { return try await attachmentsService.deleteArchivedAttachments { pendingDelete in for attachment in pendingDelete { - guard let localUri = attachment.localUri else { - continue - } - - if (try await false == self.localStorage.fileExists(filePath: localUri)) { - continue - } - + guard let localUri = attachment.localUri else { continue } + if try await !self.localStorage.fileExists(filePath: localUri) { continue } try await self.localStorage.deleteFile(filePath: localUri) } } } - - /** - * Handle downloading, uploading or deleting of attachments - */ + + /// Handles syncing for a given list of attachments. + /// + /// This includes queued downloads, uploads, and deletions. + /// + /// - Parameter attachments: The attachments to process. private func handleSync(attachments: [Attachment]) async throws { var updatedAttachments = [Attachment]() - - do { - for attachment in attachments { - let state = AttachmentState(rawValue: attachment.state) - - switch state { - case .queuedDownload: -// logger.info("Downloading \(attachment.filename)") - let updated = try await downloadAttachment(attachment: attachment) - updatedAttachments.append(updated) - - case .queuedUpload: -// logger.info("Uploading \(attachment.filename)") - let updated = try await uploadAttachment(attachment: attachment) - updatedAttachments.append(updated) - - case .queuedDelete: -// logger.info("Deleting \(attachment.filename)") - let updated = try await deleteAttachment(attachment: attachment) - updatedAttachments.append(updated) - - default: - break - } + + for attachment in attachments { + let state = AttachmentState(rawValue: attachment.state) + + switch state { + case .queuedDownload: + let updated = try await downloadAttachment(attachment: attachment) + updatedAttachments.append(updated) + case .queuedUpload: + let updated = try await uploadAttachment(attachment: attachment) + updatedAttachments.append(updated) + case .queuedDelete: + let updated = try await deleteAttachment(attachment: attachment) + updatedAttachments.append(updated) + default: + break } - - // Update the state of processed attachments - try await attachmentsService.saveAttachments(attachments: updatedAttachments) - } catch { - // We retry on the next invocation whenever there are errors at this level -// logger.error("Error during sync: \(error.localizedDescription)") - throw error } + + try await attachmentsService.saveAttachments(attachments: updatedAttachments) } - - /** - * Upload attachment from local storage to remote storage. - */ + + /// Uploads an attachment to remote storage. + /// + /// - Parameter attachment: The attachment to upload. + /// - Returns: The updated attachment with new sync state. private func uploadAttachment(attachment: Attachment) async throws -> Attachment { do { guard let localUri = attachment.localUri else { throw PowerSyncError.attachmentError("No localUri for attachment \(attachment.id)") } - + let fileData = try await localStorage.readFile(filePath: localUri) try await remoteStorage.uploadFile(fileData: fileData, attachment: attachment) - -// logger.info("Uploaded attachment \"\(attachment.id)\" to Cloud Storage") + return attachment.with(state: AttachmentState.synced.rawValue, hasSynced: 1) } catch { -// logger.error("Upload attachment error for attachment \(attachment.id): \(error.localizedDescription)") - if let errorHandler = errorHandler { let shouldRetry = await errorHandler.onUploadError(attachment: attachment, error: error) if !shouldRetry { -// logger.info("Attachment with ID \(attachment.id) has been archived") return attachment.with(state: AttachmentState.archived.rawValue) } } - - // Retry the upload (same state) return attachment } } - - /** - * Download attachment from remote storage and save it to local storage. - * Returns the updated state of the attachment. - */ + + /// Downloads an attachment from remote storage and stores it locally. + /// + /// - Parameter attachment: The attachment to download. + /// - Returns: The updated attachment with new sync state. private func downloadAttachment(attachment: Attachment) async throws -> Attachment { do { - // When downloading an attachment we take the filename and resolve - // the local_uri where the file will be stored let attachmentPath = await getLocalUri(attachment.filename) - let fileData = try await remoteStorage.downloadFile(attachment: attachment) _ = try await localStorage.saveFile(filePath: attachmentPath, data: fileData) - -// logger.info("Downloaded file \"\(attachment.id)\"") - - // The attachment has been downloaded locally + return attachment.with( state: AttachmentState.synced.rawValue, hasSynced: 1, - localUri: attachmentPath, + localUri: attachmentPath ) } catch { if let errorHandler = errorHandler { let shouldRetry = await errorHandler.onDownloadError(attachment: attachment, error: error) if !shouldRetry { -// logger.info("Attachment with ID \(attachment.id) has been archived") return attachment.with(state: AttachmentState.archived.rawValue) } } - -// logger.error("Download attachment error for attachment \(attachment.id): \(error.localizedDescription)") - // Return the same state, this will cause a retry return attachment } } - - /** - * Delete attachment from remote, local storage and then remove it from the queue. - */ + + /// Deletes an attachment from remote and local storage. + /// + /// - Parameter attachment: The attachment to delete. + /// - Returns: The updated attachment with archived state. private func deleteAttachment(attachment: Attachment) async throws -> Attachment { do { try await remoteStorage.deleteFile(attachment: attachment) - + if let localUri = attachment.localUri { try await localStorage.deleteFile(filePath: localUri) } - + return attachment.with(state: AttachmentState.archived.rawValue) } catch { if let errorHandler = errorHandler { let shouldRetry = await errorHandler.onDeleteError(attachment: attachment, error: error) if !shouldRetry { -// logger.info("Attachment with ID \(attachment.id) has been archived") return attachment.with(state: AttachmentState.archived.rawValue) } } - - // We'll retry this -// logger.error("Error deleting attachment: \(error.localizedDescription)") return attachment } } diff --git a/Tests/PowerSyncTests/Kotlin/AttachmentTests.swift b/Tests/PowerSyncTests/Kotlin/AttachmentTests.swift index cf94b78..1e5bd34 100644 --- a/Tests/PowerSyncTests/Kotlin/AttachmentTests.swift +++ b/Tests/PowerSyncTests/Kotlin/AttachmentTests.swift @@ -56,7 +56,7 @@ final class AttachmentTests: XCTestCase { return MockRemoteStorage() }(), - attachmentDirectory: NSTemporaryDirectory(), + attachmentsDirectory: NSTemporaryDirectory(), watchedAttachments: try database.watch(options: WatchOptions( sql: "SELECT photo_id FROM users WHERE photo_id IS NOT NULL", mapper: { cursor in WatchedAttachmentItem( @@ -76,13 +76,13 @@ final class AttachmentTests: XCTestCase { parameters: [] ) - var attachmentsWatch = try database.watch( + let attachmentsWatch = try database.watch( options: WatchOptions( sql: "SELECT * FROM attachments", mapper: {cursor in try Attachment.fromCursor(cursor)} )).makeAsyncIterator() - var attachmentRecord = try await waitForMatch( + let attachmentRecord = try await waitForMatch( iterator: attachmentsWatch, where: {results in results.first?.state == AttachmentState.synced.rawValue}, timeout: 5 @@ -129,7 +129,7 @@ final class AttachmentTests: XCTestCase { let queue = AttachmentQueue( db: database, remoteStorage: mockedRemote, - attachmentDirectory: NSTemporaryDirectory(), + attachmentsDirectory: NSTemporaryDirectory(), watchedAttachments: try database.watch(options: WatchOptions( sql: "SELECT photo_id FROM users WHERE photo_id IS NOT NULL", mapper: { cursor in WatchedAttachmentItem( From 7055b081d0c3380a2138272eda4d4f307c4860d8 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 8 Apr 2025 17:12:34 +0200 Subject: [PATCH 04/36] Add image preview to demo --- .../Components/TodoListRow.swift | 32 +++++++ Demo/PowerSyncExample/PowerSync/Schema.swift | 8 +- .../PowerSync/SupabaseConnector.swift | 66 +++++++------- .../PowerSync/SupabaseRemoteStorage.swift | 23 +++++ .../PowerSync/SystemManager.swift | 91 +++++++++++++++---- Demo/PowerSyncExample/PowerSync/Todos.swift | 5 +- Demo/PowerSyncExample/RootView.swift | 20 ++-- .../Screens/PhotoPicker.swift | 0 .../PowerSync/attachments/Attachment.swift | 46 +++++----- .../attachments/AttachmentsTable.swift | 24 ++--- 10 files changed, 211 insertions(+), 104 deletions(-) create mode 100644 Demo/PowerSyncExample/PowerSync/SupabaseRemoteStorage.swift create mode 100644 Demo/PowerSyncExample/Screens/PhotoPicker.swift diff --git a/Demo/PowerSyncExample/Components/TodoListRow.swift b/Demo/PowerSyncExample/Components/TodoListRow.swift index 2bbf184..4329602 100644 --- a/Demo/PowerSyncExample/Components/TodoListRow.swift +++ b/Demo/PowerSyncExample/Components/TodoListRow.swift @@ -3,10 +3,30 @@ import SwiftUI struct TodoListRow: View { let todo: Todo let completeTapped: () -> Void + @State private var image: UIImage? = nil var body: some View { HStack { Text(todo.description) + Group { + if (todo.photoUri == nil) { + // Nothing to display when photoURI is nil + EmptyView() + } else if let image = image { + Image(uiImage: image) + .resizable() + .scaledToFit() + } else if todo.photoUri != nil { + // Only show loading indicator if we have a URL string + ProgressView() + .onAppear { + loadImage() + }} + else { + EmptyView() + } + + } Spacer() Button { completeTapped() @@ -16,6 +36,18 @@ struct TodoListRow: View { .buttonStyle(.plain) } } + + private func loadImage() { + guard let urlString = todo.photoUri else { + return + } + let url = URL(fileURLWithPath: urlString) + + if let imageData = try? Data(contentsOf: url), + let loadedImage = UIImage(data: imageData) { + self.image = loadedImage + } + } } diff --git a/Demo/PowerSyncExample/PowerSync/Schema.swift b/Demo/PowerSyncExample/PowerSync/Schema.swift index 7355a31..539e165 100644 --- a/Demo/PowerSyncExample/PowerSync/Schema.swift +++ b/Demo/PowerSyncExample/PowerSync/Schema.swift @@ -10,7 +10,7 @@ let lists = Table( // ID column is automatically included .text("name"), .text("created_at"), - .text("owner_id") + .text("owner_id"), ] ) @@ -26,14 +26,14 @@ let todos = Table( Column.text("created_at"), Column.text("completed_at"), Column.text("created_by"), - Column.text("completed_by") + Column.text("completed_by"), ], indexes: [ Index( name: "list_id", columns: [IndexedColumn.ascending("list_id")] - ) + ), ] ) -let AppSchema = Schema(lists, todos) +let AppSchema = Schema(lists, todos, createAttachmentsTable(name: "attachments")) diff --git a/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift b/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift index fe5b184..1f5f5f7 100644 --- a/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift +++ b/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift @@ -1,10 +1,10 @@ +import AnyCodable import Auth -import SwiftUI -import Supabase import PowerSync -import AnyCodable +import Supabase +import SwiftUI -private struct PostgresFatalCodes { +private enum PostgresFatalCodes { /// Postgres Response codes that we cannot recover from by retrying. static let fatalResponseCodes: [String] = [ // Class 22 — Data Exception @@ -14,7 +14,7 @@ private struct PostgresFatalCodes { // Examples include NOT NULL, FOREIGN KEY and UNIQUE violations. "23...", // INSUFFICIENT PRIVILEGE - typically a row-level security violation - "42501" + "42501", ] static func isFatalError(_ code: String) -> Bool { @@ -27,20 +27,20 @@ private struct PostgresFatalCodes { // Look for code: Optional("XXXXX") pattern let errorString = String(describing: error) if let range = errorString.range(of: "code: Optional\\(\"([^\"]+)\"\\)", options: .regularExpression), - let codeRange = errorString[range].range(of: "\"([^\"]+)\"", options: .regularExpression) { - // Extract just the code from within the quotes - let code = errorString[codeRange].dropFirst().dropLast() - return String(code) - } + let codeRange = errorString[range].range(of: "\"([^\"]+)\"", options: .regularExpression) + { + // Extract just the code from within the quotes + let code = errorString[codeRange].dropFirst().dropLast() + return String(code) + } return nil } } - @Observable class SupabaseConnector: PowerSyncBackendConnector { let powerSyncEndpoint: String = Secrets.powerSyncEndpoint - let client: SupabaseClient = SupabaseClient(supabaseURL: Secrets.supabaseURL, supabaseKey: Secrets.supabaseAnonKey) + let client: SupabaseClient = .init(supabaseURL: Secrets.supabaseURL, supabaseKey: Secrets.supabaseAnonKey) var session: Session? private var errorCode: String? @@ -68,20 +68,23 @@ class SupabaseConnector: PowerSyncBackendConnector { return id.uuidString.lowercased() } + func getStorageBucket() -> StorageFileApi { + return client.storage.from(Secrets.supabaseStorageBucket) + } + override func fetchCredentials() async throws -> PowerSyncCredentials? { session = try await client.auth.session - if (self.session == nil) { + if session == nil { throw AuthError.sessionMissing } let token = session!.accessToken - return PowerSyncCredentials(endpoint: self.powerSyncEndpoint, token: token) + return PowerSyncCredentials(endpoint: powerSyncEndpoint, token: token) } override func uploadData(database: PowerSyncDatabaseProtocol) async throws { - guard let transaction = try await database.getNextCrudTransaction() else { return } var lastEntry: CrudEntry? @@ -96,13 +99,13 @@ class SupabaseConnector: PowerSyncBackendConnector { case .put: var data: [String: AnyCodable] = entry.opData?.mapValues { AnyCodable($0) } ?? [:] data["id"] = AnyCodable(entry.id) - try await table.upsert(data).execute(); + try await table.upsert(data).execute() case .patch: guard let opData = entry.opData else { continue } let encodableData = opData.mapValues { AnyCodable($0) } try await table.update(encodableData).eq("id", value: entry.id).execute() case .delete: - try await table.delete().eq( "id", value: entry.id).execute() + try await table.delete().eq("id", value: entry.id).execute() } } @@ -110,25 +113,22 @@ class SupabaseConnector: PowerSyncBackendConnector { } catch { if let errorCode = PostgresFatalCodes.extractErrorCode(from: error), - PostgresFatalCodes.isFatalError(errorCode) { - /// Instead of blocking the queue with these errors, - /// discard the (rest of the) transaction. - /// - /// Note that these errors typically indicate a bug in the application. - /// If protecting against data loss is important, save the failing records - /// elsewhere instead of discarding, and/or notify the user. - print("Data upload error: \(error)") - print("Discarding entry: \(lastEntry!)") - _ = try await transaction.complete.invoke(p1: nil) - return - } + PostgresFatalCodes.isFatalError(errorCode) + { + /// Instead of blocking the queue with these errors, + /// discard the (rest of the) transaction. + /// + /// Note that these errors typically indicate a bug in the application. + /// If protecting against data loss is important, save the failing records + /// elsewhere instead of discarding, and/or notify the user. + print("Data upload error: \(error)") + print("Discarding entry: \(lastEntry!)") + _ = try await transaction.complete.invoke(p1: nil) + return + } print("Data upload error - retrying last entry: \(lastEntry!), \(error)") throw error } } - - deinit { - observeAuthStateChangesTask?.cancel() - } } diff --git a/Demo/PowerSyncExample/PowerSync/SupabaseRemoteStorage.swift b/Demo/PowerSyncExample/PowerSync/SupabaseRemoteStorage.swift new file mode 100644 index 0000000..a79ca95 --- /dev/null +++ b/Demo/PowerSyncExample/PowerSync/SupabaseRemoteStorage.swift @@ -0,0 +1,23 @@ +import Foundation +import PowerSync +import Supabase + +class SupabaseRemoteStorage: RemoteStorageAdapter { + let storage: Supabase.StorageFileApi + + init(storage: Supabase.StorageFileApi) { + self.storage = storage + } + + func uploadFile(fileData: Data, attachment: PowerSync.Attachment) async throws { + try await storage.upload(attachment.filename, data: fileData) + } + + func downloadFile(attachment: PowerSync.Attachment) async throws -> Data { + try await storage.download(path: attachment.filename) + } + + func deleteFile(attachment: PowerSync.Attachment) async throws { + _ = try await storage.remove(paths: [attachment.filename]) + } +} diff --git a/Demo/PowerSyncExample/PowerSync/SystemManager.swift b/Demo/PowerSyncExample/PowerSync/SystemManager.swift index cd0aa0b..eb922b5 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -1,26 +1,70 @@ import Foundation import PowerSync +func getAttachmentsDirectoryPath() throws -> String { + guard let documentsURL = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + ).first else { + throw PowerSyncError.attachmentError("Could not determine attachments directory path") + } + let r = documentsURL.appendingPathComponent("attachments").path + + return r +} + @Observable class SystemManager { let connector = SupabaseConnector() let schema = AppSchema - var db: PowerSyncDatabaseProtocol! + let db: PowerSyncDatabaseProtocol + let attachments: AttachmentQueue? + + init() { + db = PowerSyncDatabase( + schema: schema, + dbFilename: "powersync-swift.sqlite" + ) + // Try and configure attachments + do { + let attachmentsDir = try getAttachmentsDirectoryPath() + let watchedAttachments = try db.watch( + options: WatchOptions( + sql: "SELECT photo_id FROM \(TODOS_TABLE) WHERE photo_id IS NOT NULL", + parameters: [], + mapper: { cursor in + try WatchedAttachmentItem( + id: cursor.getString(name: "photo_id"), + fileExtension: "jpg" + ) + } + ) + ) - // openDb must be called before connect - func openDb() { - db = PowerSyncDatabase(schema: schema, dbFilename: "powersync-swift.sqlite") + attachments = AttachmentQueue( + db: db, + remoteStorage: SupabaseRemoteStorage(storage: connector.getStorageBucket()), + attachmentsDirectory: attachmentsDir, + watchedAttachments: watchedAttachments + ) + } catch { + print("Failed to initialize attachments queue: \(error)") + attachments = nil + } } func connect() async { do { + // Only for testing purposes + try await attachments?.clearQueue() try await db.connect(connector: connector) + try await attachments?.startSync() } catch { print("Unexpected error: \(error.localizedDescription)") // Catches any other error } } - func version() async -> String { + func version() async -> String { do { return try await db.getPowerSyncVersion() } catch { @@ -28,14 +72,16 @@ class SystemManager { } } - func signOut() async throws -> Void { + func signOut() async throws { try await db.disconnectAndClear() try await connector.client.auth.signOut() + try await attachments?.clearQueue() + try await attachments?.close() } - func watchLists(_ callback: @escaping (_ lists: [ListContent]) -> Void ) async { + func watchLists(_ callback: @escaping (_ lists: [ListContent]) -> Void) async { do { - for try await lists in try self.db.watch( + for try await lists in try db.watch( options: WatchOptions( sql: "SELECT * FROM \(LISTS_TABLE)", mapper: { cursor in @@ -56,7 +102,7 @@ class SystemManager { } func insertList(_ list: NewListContent) async throws { - let result = try await self.db.execute( + let result = try await db.execute( sql: "INSERT INTO \(LISTS_TABLE) (id, created_at, name, owner_id) VALUES (uuid(), datetime(), ?, ?)", parameters: [list.name, connector.currentUserID] ) @@ -72,14 +118,22 @@ class SystemManager { sql: "DELETE FROM \(TODOS_TABLE) WHERE list_id = ?", parameters: [id] ) - return }) } - func watchTodos(_ listId: String, _ callback: @escaping (_ todos: [Todo]) -> Void ) async { + func watchTodos(_ listId: String, _ callback: @escaping (_ todos: [Todo]) -> Void) async { do { - for try await todos in try self.db.watch( - sql: "SELECT * FROM \(TODOS_TABLE) WHERE list_id = ?", + for try await todos in try db.watch( + sql: """ + SELECT + t.*, a.local_uri + FROM + \(TODOS_TABLE) t + LEFT JOIN attachments a ON t.photo_id = a.id + WHERE + t.list_id = ? + ORDER BY t.id; + """, parameters: [listId], mapper: { cursor in try Todo( @@ -91,7 +145,8 @@ class SystemManager { createdAt: cursor.getString(name: "created_at"), completedAt: cursor.getStringOptional(name: "completed_at"), createdBy: cursor.getStringOptional(name: "created_by"), - completedBy: cursor.getStringOptional(name: "completed_by") + completedBy: cursor.getStringOptional(name: "completed_by"), + photoUri: cursor.getStringOptional(name: "local_uri") ) } ) { @@ -103,7 +158,7 @@ class SystemManager { } func insertTodo(_ todo: NewTodo, _ listId: String) async throws { - _ = try await self.db.execute( + _ = try await db.execute( sql: "INSERT INTO \(TODOS_TABLE) (id, created_at, created_by, description, list_id, completed) VALUES (uuid(), datetime(), ?, ?, ?, ?)", parameters: [connector.currentUserID, todo.description, listId, todo.isComplete] ) @@ -111,13 +166,13 @@ class SystemManager { func updateTodo(_ todo: Todo) async throws { // Do this to avoid needing to handle date time from Swift to Kotlin - if(todo.isComplete) { - _ = try await self.db.execute( + if todo.isComplete { + _ = try await db.execute( sql: "UPDATE \(TODOS_TABLE) SET description = ?, completed = ?, completed_at = datetime(), completed_by = ? WHERE id = ?", parameters: [todo.description, todo.isComplete, connector.currentUserID, todo.id] ) } else { - _ = try await self.db.execute( + _ = try await db.execute( sql: "UPDATE \(TODOS_TABLE) SET description = ?, completed = ?, completed_at = NULL, completed_by = NULL WHERE id = ?", parameters: [todo.description, todo.isComplete, todo.id] ) diff --git a/Demo/PowerSyncExample/PowerSync/Todos.swift b/Demo/PowerSyncExample/PowerSync/Todos.swift index dd53e31..fce55f3 100644 --- a/Demo/PowerSyncExample/PowerSync/Todos.swift +++ b/Demo/PowerSyncExample/PowerSync/Todos.swift @@ -11,7 +11,8 @@ struct Todo: Identifiable, Hashable, Decodable { var completedAt: String? var createdBy: String? var completedBy: String? - + var photoUri: String? + enum CodingKeys: String, CodingKey { case id case listId = "list_id" @@ -22,7 +23,7 @@ struct Todo: Identifiable, Hashable, Decodable { case createdBy = "created_by" case completedBy = "completed_by" case photoId = "photo_id" - + case photoUri = "photo_uri" } } diff --git a/Demo/PowerSyncExample/RootView.swift b/Demo/PowerSyncExample/RootView.swift index 9450aa1..e4a03e1 100644 --- a/Demo/PowerSyncExample/RootView.swift +++ b/Demo/PowerSyncExample/RootView.swift @@ -18,24 +18,18 @@ struct RootView: View { } .navigationDestination(for: Route.self) { route in switch route { - case .home: - HomeScreen() - case .signIn: - SignInScreen() - case .signUp: - SignUpScreen() - } - } - } - .task { - if(system.db == nil) { - system.openDb() + case .home: + HomeScreen() + case .signIn: + SignInScreen() + case .signUp: + SignUpScreen() + } } } .environment(authModel) .environment(navigationModel) } - } #Preview { diff --git a/Demo/PowerSyncExample/Screens/PhotoPicker.swift b/Demo/PowerSyncExample/Screens/PhotoPicker.swift new file mode 100644 index 0000000..e69de29 diff --git a/Sources/PowerSync/attachments/Attachment.swift b/Sources/PowerSync/attachments/Attachment.swift index 66d00a6..017cd08 100644 --- a/Sources/PowerSync/attachments/Attachment.swift +++ b/Sources/PowerSync/attachments/Attachment.swift @@ -15,32 +15,32 @@ public enum AttachmentState: Int { /// Struct representing an attachment public struct Attachment { /// Unique identifier for the attachment - let id: String + public let id: String /// Timestamp for the last record update - let timestamp: Int + public let timestamp: Int /// Attachment filename, e.g. `[id].jpg` - let filename: String + public let filename: String /// Current attachment state, represented by the raw value of `AttachmentState` - let state: Int + public let state: Int /// Local URI pointing to the attachment file - let localUri: String? + public let localUri: String? /// Attachment media type (usually a MIME type) - let mediaType: String? + public let mediaType: String? /// Attachment byte size - let size: Int64? + public let size: Int64? /// Specifies if the attachment has been synced locally before. /// This is particularly useful for restoring archived attachments in edge cases. - let hasSynced: Int? + public let hasSynced: Int? /// Extra attachment metadata - let metaData: String? + public let metaData: String? /// Initializes a new `Attachment` instance public init( @@ -78,9 +78,9 @@ public struct Attachment { /// - metaData: Optional new metadata. /// - Returns: A new `Attachment` with updated values. func with( - filename: String? = nil, + filename _: String? = nil, state: Int? = nil, - timestamp: Int = 0, + timestamp _: Int = 0, hasSynced: Int? = 0, localUri: String? = nil, mediaType: String? = nil, @@ -88,8 +88,8 @@ public struct Attachment { metaData: String? = nil ) -> Attachment { return Attachment( - id: self.id, - filename: self.filename, + id: id, + filename: filename, state: state ?? self.state, hasSynced: hasSynced ?? self.hasSynced, localUri: localUri ?? self.localUri, @@ -105,16 +105,16 @@ public struct Attachment { /// - Throws: If required fields are missing or of incorrect type. /// - Returns: A fully constructed `Attachment` instance. public static func fromCursor(_ cursor: SqlCursor) throws -> Attachment { - return Attachment( - id: try cursor.getString(name: "id"), - filename: try cursor.getString(name: "filename"), - state: try cursor.getLong(name: "state"), - timestamp: try cursor.getLong(name: "timestamp"), - hasSynced: try cursor.getLongOptional(name: "has_synced"), - localUri: try cursor.getStringOptional(name: "local_uri"), - mediaType: try cursor.getStringOptional(name: "media_type"), - size: try cursor.getLongOptional(name: "size")?.int64Value, - metaData: try cursor.getStringOptional(name: "meta_data") + return try Attachment( + id: cursor.getString(name: "id"), + filename: cursor.getString(name: "filename"), + state: cursor.getLong(name: "state"), + timestamp: cursor.getLong(name: "timestamp"), + hasSynced: cursor.getLongOptional(name: "has_synced"), + localUri: cursor.getStringOptional(name: "local_uri"), + mediaType: cursor.getStringOptional(name: "media_type"), + size: cursor.getLongOptional(name: "size")?.int64Value, + metaData: cursor.getStringOptional(name: "meta_data") ) } } diff --git a/Sources/PowerSync/attachments/AttachmentsTable.swift b/Sources/PowerSync/attachments/AttachmentsTable.swift index 8214b95..1e4ea08 100644 --- a/Sources/PowerSync/attachments/AttachmentsTable.swift +++ b/Sources/PowerSync/attachments/AttachmentsTable.swift @@ -1,12 +1,14 @@ -func createAttachmentsTable(name: String) -> Table { - return Table(name: name, columns: [ - .integer("timestamp"), - .integer("state"), - .text("filename"), - .integer("has_synced"), - .text("local_uri"), - .text("media_type"), - .integer("size"), - .text("meta_data") - ]) +public func createAttachmentsTable(name: String) -> Table { + return Table( + name: name, columns: [ + .integer("timestamp"), + .integer("state"), + .text("filename"), + .integer("has_synced"), + .text("local_uri"), + .text("media_type"), + .integer("size"), + .text("meta_data"), + ], localOnly: true + ) } From 671761b8c6e2ae10efb7c8feef57cae438ff7875 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 8 Apr 2025 17:21:54 +0200 Subject: [PATCH 05/36] format --- .../attachments/AttachmentQueue.swift | 169 +++++++++--------- .../attachments/AttachmentsService.swift | 69 +++---- .../attachments/FileManagerLocalStorage.swift | 46 ++--- .../PowerSync/attachments/LocalStorage.swift | 22 ++- .../PowerSync/attachments/RemoteStorage.swift | 5 +- .../attachments/SyncErrorHandler.swift | 7 +- .../attachments/SyncingService.swift | 2 +- 7 files changed, 159 insertions(+), 161 deletions(-) diff --git a/Sources/PowerSync/attachments/AttachmentQueue.swift b/Sources/PowerSync/attachments/AttachmentQueue.swift index 0a35589..efd2e15 100644 --- a/Sources/PowerSync/attachments/AttachmentQueue.swift +++ b/Sources/PowerSync/attachments/AttachmentQueue.swift @@ -1,8 +1,8 @@ -import Foundation import Combine +import Foundation import OSLog -//TODO should not need this +// TODO: should not need this import PowerSyncKotlin /// A watched attachment record item. @@ -10,16 +10,16 @@ import PowerSyncKotlin public struct WatchedAttachmentItem { /// Id for the attachment record public let id: String - + /// File extension used to determine an internal filename for storage if no `filename` is provided public let fileExtension: String? - + /// Filename to store the attachment with public let filename: String? - + /// Metadata for the attachment (optional) public let metaData: String? - + /// Initializes a new `WatchedAttachmentItem` /// - Parameters: /// - id: Attachment record ID @@ -36,83 +36,80 @@ public struct WatchedAttachmentItem { self.fileExtension = fileExtension self.filename = filename self.metaData = metaData - + precondition(fileExtension != nil || filename != nil, "Either fileExtension or filename must be provided.") } } - /// Class used to implement the attachment queue /// Requires a PowerSyncDatabase, a RemoteStorageAdapter implementation, and a directory name for attachments. public actor AttachmentQueue { /// Default name of the attachments table public static let DEFAULT_TABLE_NAME = "attachments" - + /// PowerSync database client public let db: PowerSyncDatabaseProtocol - + /// Remote storage adapter public let remoteStorage: RemoteStorageAdapter - + /// Directory name for attachments private let attachmentsDirectory: String - + /// Stream of watched attachments private let watchedAttachments: AsyncThrowingStream<[WatchedAttachmentItem], Error> - + /// Local file system adapter public let localStorage: LocalStorageAdapter - + /// Attachments table name in SQLite private let attachmentsQueueTableName: String - + /// Optional sync error handler private let errorHandler: SyncErrorHandler? - + /// Interval between periodic syncs private let syncInterval: TimeInterval - + /// Limit on number of archived attachments private let archivedCacheLimit: Int64 - + /// Duration for throttling sync operations private let syncThrottleDuration: TimeInterval - + /// Subdirectories to be created in attachments directory private let subdirectories: [String]? - + /// Whether to allow downloading of attachments private let downloadAttachments: Bool - + /** * Logging interface used for all log operations */ // public let logger: Logger - + /// Attachment service for interacting with attachment records public let attachmentsService: AttachmentService - + private var syncStatusTask: Task? private var cancellables = Set() - + /// Indicates whether the queue has been closed public private(set) var closed: Bool = false - + /// Syncing service instance - private(set) lazy var syncingService: SyncingService = { - return SyncingService( - remoteStorage: self.remoteStorage, - localStorage: self.localStorage, - attachmentsService: self.attachmentsService, - getLocalUri: { [weak self] filename in - guard let self = self else { return filename } - return await self.getLocalUri(filename) - }, - errorHandler: self.errorHandler, - syncThrottle: self.syncThrottleDuration - ) - }() - + private(set) lazy var syncingService: SyncingService = .init( + remoteStorage: self.remoteStorage, + localStorage: self.localStorage, + attachmentsService: self.attachmentsService, + getLocalUri: { [weak self] filename in + guard let self = self else { return filename } + return await self.getLocalUri(filename) + }, + errorHandler: self.errorHandler, + syncThrottle: self.syncThrottleDuration + ) + /// Initializes the attachment queue /// - Parameters match the stored properties public init( @@ -143,39 +140,39 @@ public actor AttachmentQueue { self.subdirectories = subdirectories self.downloadAttachments = downloadAttachments // self.logger = logger - - self.attachmentsService = AttachmentService( + + attachmentsService = AttachmentService( db: db, tableName: attachmentsQueueTableName, // logger: logger, maxArchivedCount: archivedCacheLimit ) } - + /// Starts the attachment sync process public func startSync() async throws { if closed { throw NSError(domain: "AttachmentError", code: 4, userInfo: [NSLocalizedDescriptionKey: "Attachment queue has been closed"]) } - + // Ensure the directory where attachments are downloaded exists try await localStorage.makeDir(path: attachmentsDirectory) - + if let subdirectories = subdirectories { for subdirectory in subdirectories { let path = URL(fileURLWithPath: attachmentsDirectory).appendingPathComponent(subdirectory).path try await localStorage.makeDir(path: path) } } - + await syncingService.startPeriodicSync(period: syncInterval) - + syncStatusTask = Task { do { // Create a task for watching connectivity changes let connectivityTask = Task { var previousConnected = db.currentStatus.connected - + for await status in db.currentStatus.asFlow() { if !previousConnected && status.connected { await syncingService.triggerSync() @@ -183,14 +180,14 @@ public actor AttachmentQueue { previousConnected = status.connected } } - + // Create a task for watching attachment changes let watchTask = Task { for try await items in self.watchedAttachments { try await self.processWatchedAttachments(items: items) } } - + // Wait for both tasks to complete (they shouldn't unless cancelled) await connectivityTask.value try await watchTask.value @@ -201,18 +198,18 @@ public actor AttachmentQueue { } } } - + /// Closes the attachment queue and cancels all sync tasks public func close() async throws { if closed { return } - + syncStatusTask?.cancel() await syncingService.close() closed = true } - + /// Resolves the filename for a new attachment /// - Parameters: /// - attachmentId: Attachment ID @@ -224,7 +221,7 @@ public actor AttachmentQueue { ) -> String { return "\(attachmentId).\(fileExtension ?? "")" } - + /// Processes watched attachment items and updates sync state /// - Parameter items: List of watched attachment items public func processWatchedAttachments(items: [WatchedAttachmentItem]) async throws { @@ -232,10 +229,10 @@ public actor AttachmentQueue { // We might need to restore an archived attachment. let currentAttachments = try await attachmentsService.getAttachments() var attachmentUpdates = [Attachment]() - + for item in items { let existingQueueItem = currentAttachments.first { $0.id == item.id } - + if existingQueueItem == nil { if !downloadAttachments { continue @@ -248,7 +245,7 @@ public actor AttachmentQueue { attachmentId: item.id, fileExtension: item.fileExtension ) - + attachmentUpdates.append( Attachment( id: item.id, @@ -265,44 +262,45 @@ public actor AttachmentQueue { existingQueueItem!.with(state: AttachmentState.synced.rawValue) ) } else { - // The localURI should be set if the record was meant to be downloaded - // and has been synced. If it's missing and hasSynced is false then - // it must be an upload operation + // The localURI should be set if the record was meant to be downloaded + // and has been synced. If it's missing and hasSynced is false then + // it must be an upload operation let newState = existingQueueItem!.localUri == nil ? AttachmentState.queuedDownload.rawValue : AttachmentState.queuedUpload.rawValue - + attachmentUpdates.append( existingQueueItem!.with(state: newState) ) } } } - + /** * Archive any items not specified in the watched items except for items pending delete. */ for attachment in currentAttachments { - if attachment.state != AttachmentState.queuedDelete.rawValue && - items.first(where: { $0.id == attachment.id }) == nil { + if attachment.state != AttachmentState.queuedDelete.rawValue, + items.first(where: { $0.id == attachment.id }) == nil + { attachmentUpdates.append( attachment.with(state: AttachmentState.archived.rawValue) ) } } - + if !attachmentUpdates.isEmpty { try await attachmentsService.saveAttachments(attachments: attachmentUpdates) } } - + /// Saves a new file and schedules it for upload - /// - Parameters: - /// - data: File data - /// - mediaType: MIME type - /// - fileExtension: File extension - /// - updateHook: Hook to assign attachment relationships in the same transaction - /// - Returns: The created attachment + /// - Parameters: + /// - data: File data + /// - mediaType: MIME type + /// - fileExtension: File extension + /// - updateHook: Hook to assign attachment relationships in the same transaction + /// - Returns: The created attachment public func saveFile( data: Data, mediaType: String, @@ -310,14 +308,15 @@ public actor AttachmentQueue { updateHook: @escaping (PowerSyncTransaction, Attachment) throws -> Void ) async throws -> Attachment { let id = try await db.get(sql: "SELECT uuid() as id", parameters: [], mapper: { cursor in - try cursor.getString(name: "id") }) - + try cursor.getString(name: "id") + }) + let filename = resolveNewAttachmentFilename(attachmentId: id, fileExtension: fileExtension) let localUri = getLocalUri(filename) - + // Write the file to the filesystem let fileSize = try await localStorage.saveFile(filePath: localUri, data: data) - + // Start a write transaction. The attachment record and relevant local relationship // assignment should happen in the same transaction. return try await db.writeTransaction { tx in @@ -329,14 +328,14 @@ public actor AttachmentQueue { mediaType: mediaType, size: fileSize ) - + // Allow consumers to set relationships to this attachment id try updateHook(tx, attachment) - + return try self.attachmentsService.upsertAttachment(attachment, context: tx) } } - + /// Queues a file for deletion /// - Parameters: /// - attachmentId: ID of the attachment to delete @@ -348,10 +347,10 @@ public actor AttachmentQueue { guard let attachment = try await attachmentsService.getAttachment(id: attachmentId) else { throw NSError(domain: "AttachmentError", code: 5, userInfo: [NSLocalizedDescriptionKey: "Attachment record with id \(attachmentId) was not found."]) } - + return try await db.writeTransaction { tx in try updateHook(tx, attachment) - + let updatedAttachment = Attachment( id: attachment.id, filename: attachment.filename, @@ -361,26 +360,26 @@ public actor AttachmentQueue { mediaType: attachment.mediaType, size: attachment.size, ) - + return try self.attachmentsService.upsertAttachment(updatedAttachment, context: tx) } } - + /// Returns the local URI where a file is stored based on filename /// - Parameter filename: The name of the file /// - Returns: The file path public func getLocalUri(_ filename: String) -> String { return URL(fileURLWithPath: attachmentsDirectory).appendingPathComponent(filename).path } - + /// Removes all archived items public func expireCache() async throws { var done = false repeat { - done = try await self.syncingService.deleteArchivedAttachments() + done = try await syncingService.deleteArchivedAttachments() } while !done } - + /// Clears the attachment queue and deletes all attachment files public func clearQueue() async throws { try await attachmentsService.clearQueue() diff --git a/Sources/PowerSync/attachments/AttachmentsService.swift b/Sources/PowerSync/attachments/AttachmentsService.swift index 0931e85..3fbf341 100644 --- a/Sources/PowerSync/attachments/AttachmentsService.swift +++ b/Sources/PowerSync/attachments/AttachmentsService.swift @@ -1,5 +1,6 @@ import Foundation -//TODO should not need this + +// TODO: should not need this import PowerSyncKotlin /** @@ -10,14 +11,14 @@ public class AttachmentService { private let tableName: String // private let logger: Logger private let maxArchivedCount: Int64 - + /** * Table used for storing attachments in the attachment queue. */ private var table: String { return tableName } - + public init( db: PowerSyncDatabaseProtocol, tableName: String, @@ -29,24 +30,24 @@ public class AttachmentService { // self.logger = logger self.maxArchivedCount = maxArchivedCount } - + /** * Delete the attachment from the attachment queue. */ public func deleteAttachment(id: String) async throws { _ = try await db.execute(sql: "DELETE FROM \(table) WHERE id = ?", parameters: [id]) } - + /** * Set the state of the attachment to ignore. */ public func ignoreAttachment(id: String) async throws { _ = try await db.execute( - sql: "UPDATE \(table) SET state = ? WHERE id = ?", + sql: "UPDATE \(table) SET state = ? WHERE id = ?", parameters: [AttachmentState.archived.rawValue, id] ) } - + /** * Get the attachment from the attachment queue using an ID. */ @@ -55,7 +56,7 @@ public class AttachmentService { try Attachment.fromCursor(cursor) }) } - + /** * Save the attachment to the attachment queue. */ @@ -64,7 +65,7 @@ public class AttachmentService { try self.upsertAttachment(attachment, context: ctx) } } - + /** * Save the attachments to the attachment queue. */ @@ -72,14 +73,14 @@ public class AttachmentService { if attachments.isEmpty { return } - + try await db.writeTransaction { tx in for attachment in attachments { _ = try self.upsertAttachment(attachment, context: tx) } } } - + /** * Get all the ID's of attachments in the attachment queue. */ @@ -87,11 +88,12 @@ public class AttachmentService { return try await db.getAll( sql: "SELECT id FROM \(table) WHERE id IS NOT NULL", parameters: [], - mapper: {cursor in - try cursor.getString(name: "id") - }) + mapper: { cursor in + try cursor.getString(name: "id") + } + ) } - + public func getAttachments() async throws -> [Attachment] { return try await db.getAll( sql: """ @@ -105,11 +107,12 @@ public class AttachmentService { timestamp ASC """, parameters: [], - mapper: { cursor in - try Attachment.fromCursor(cursor) - }) + mapper: { cursor in + try Attachment.fromCursor(cursor) + } + ) } - + /** * Gets all the active attachments which require an operation to be performed. */ @@ -136,16 +139,16 @@ public class AttachmentService { try Attachment.fromCursor(cursor) } } - + /** * Watcher for changes to attachments table. * Once a change is detected it will initiate a sync of the attachments */ public func watchActiveAttachments() throws -> AsyncThrowingStream<[String], Error> { // logger.i("Watching attachments...") - + return try db.watch( - sql: """ + sql: """ SELECT id FROM @@ -166,16 +169,16 @@ public class AttachmentService { try cursor.getString(name: "id") } } - + /** * Helper function to clear the attachment queue * Currently only used for testing purposes. */ public func clearQueue() async throws { - //logger.i("Clearing attachment queue...") + // logger.i("Clearing attachment queue...") _ = try await db.execute("DELETE FROM \(table)") } - + /** * Delete attachments which have been archived * @returns true if all items have been deleted. Returns false if there might be more archived @@ -204,20 +207,20 @@ public class AttachmentService { ) { cursor in try Attachment.fromCursor(cursor) } - + try await callback(attachments) - + let ids = try JSONEncoder().encode(attachments.map { $0.id }) let idsString = String(data: ids, encoding: .utf8)! - + _ = try await db.execute( - sql: "DELETE FROM \(table) WHERE id IN (SELECT value FROM json_each(?));", + sql: "DELETE FROM \(table) WHERE id IN (SELECT value FROM json_each(?));", parameters: [idsString] ) - + return attachments.count < limit } - + /** * Upserts an attachment record synchronously given a database connection context. */ @@ -236,7 +239,7 @@ public class AttachmentService { mediaType: attachment.mediaType, size: attachment.size, ) - + try context.execute( sql: """ INSERT OR REPLACE INTO @@ -255,7 +258,7 @@ public class AttachmentService { updatedRecord.hasSynced ?? 0, ] ) - + return attachment } } diff --git a/Sources/PowerSync/attachments/FileManagerLocalStorage.swift b/Sources/PowerSync/attachments/FileManagerLocalStorage.swift index 3952104..25afd6f 100644 --- a/Sources/PowerSync/attachments/FileManagerLocalStorage.swift +++ b/Sources/PowerSync/attachments/FileManagerLocalStorage.swift @@ -5,33 +5,33 @@ import Foundation */ public class FileManagerStorageAdapter: LocalStorageAdapter { private let fileManager = FileManager.default - - public init () {} - + + public init() {} + public func saveFile(filePath: String, data: Data) async throws -> Int64 { return try await Task { let url = URL(fileURLWithPath: filePath) - + // Make sure the parent directory exists try fileManager.createDirectory(at: url.deletingLastPathComponent(), - withIntermediateDirectories: true) - + withIntermediateDirectories: true) + // Write data to file try data.write(to: url) - + // Return the size of the data return Int64(data.count) }.value } - - public func readFile(filePath: String, mediaType: String?) async throws -> Data { + + public func readFile(filePath: String, mediaType _: String?) async throws -> Data { return try await Task { let url = URL(fileURLWithPath: filePath) - + if !fileManager.fileExists(atPath: filePath) { throw PowerSyncError.fileNotFound(filePath) } - + // Read data from file do { return try Data(contentsOf: url) @@ -40,7 +40,7 @@ public class FileManagerStorageAdapter: LocalStorageAdapter { } }.value } - + public func deleteFile(filePath: String) async throws { try await Task { if fileManager.fileExists(atPath: filePath) { @@ -48,21 +48,21 @@ public class FileManagerStorageAdapter: LocalStorageAdapter { } }.value } - + public func fileExists(filePath: String) async throws -> Bool { return await Task { - return fileManager.fileExists(atPath: filePath) + fileManager.fileExists(atPath: filePath) }.value } - + public func makeDir(path: String) async throws { try await Task { try fileManager.createDirectory(atPath: path, - withIntermediateDirectories: true, - attributes: nil) + withIntermediateDirectories: true, + attributes: nil) }.value } - + public func rmDir(path: String) async throws { try await Task { if fileManager.fileExists(atPath: path) { @@ -70,23 +70,23 @@ public class FileManagerStorageAdapter: LocalStorageAdapter { } }.value } - + public func copyFile(sourcePath: String, targetPath: String) async throws { try await Task { if !fileManager.fileExists(atPath: sourcePath) { throw PowerSyncError.fileNotFound(sourcePath) } - + // Ensure target directory exists let targetUrl = URL(fileURLWithPath: targetPath) try fileManager.createDirectory(at: targetUrl.deletingLastPathComponent(), - withIntermediateDirectories: true) - + withIntermediateDirectories: true) + // If target already exists, remove it first if fileManager.fileExists(atPath: targetPath) { try fileManager.removeItem(atPath: targetPath) } - + try fileManager.copyItem(atPath: sourcePath, toPath: targetPath) }.value } diff --git a/Sources/PowerSync/attachments/LocalStorage.swift b/Sources/PowerSync/attachments/LocalStorage.swift index daf8b65..4136db6 100644 --- a/Sources/PowerSync/attachments/LocalStorage.swift +++ b/Sources/PowerSync/attachments/LocalStorage.swift @@ -4,23 +4,22 @@ import Foundation public enum PowerSyncError: Error { /// A general error with an associated message case generalError(String) - + /// Indicates that a file was not found at the given path case fileNotFound(String) - + /// An I/O error occurred case ioError(Error) - + /// The given file or directory path was invalid case invalidPath(String) - + /// An error related to attachment handling case attachmentError(String) } /// Protocol defining an adapter interface for local file storage public protocol LocalStorageAdapter { - /// Saves data to a file at the specified path. /// /// - Parameters: @@ -32,7 +31,7 @@ public protocol LocalStorageAdapter { filePath: String, data: Data ) async throws -> Int64 - + /// Reads a file from the specified path. /// /// - Parameters: @@ -44,32 +43,32 @@ public protocol LocalStorageAdapter { filePath: String, mediaType: String? ) async throws -> Data - + /// Deletes a file at the specified path. /// /// - Parameter filePath: The full path to the file to delete. /// - Throws: `PowerSyncError` if deletion fails or file doesn't exist. func deleteFile(filePath: String) async throws - + /// Checks if a file exists at the specified path. /// /// - Parameter filePath: The path to the file. /// - Returns: `true` if the file exists, `false` otherwise. /// - Throws: `PowerSyncError` if checking fails. func fileExists(filePath: String) async throws -> Bool - + /// Creates a directory at the specified path. /// /// - Parameter path: The full path to the directory. /// - Throws: `PowerSyncError` if creation fails. func makeDir(path: String) async throws - + /// Removes a directory at the specified path. /// /// - Parameter path: The full path to the directory. /// - Throws: `PowerSyncError` if removal fails. func rmDir(path: String) async throws - + /// Copies a file from the source path to the target path. /// /// - Parameters: @@ -84,7 +83,6 @@ public protocol LocalStorageAdapter { /// Extension providing a default implementation of `readFile` without a media type public extension LocalStorageAdapter { - /// Reads a file from the specified path without specifying a media type. /// /// - Parameter filePath: The full path to the file. diff --git a/Sources/PowerSync/attachments/RemoteStorage.swift b/Sources/PowerSync/attachments/RemoteStorage.swift index 27ba631..bd94a42 100644 --- a/Sources/PowerSync/attachments/RemoteStorage.swift +++ b/Sources/PowerSync/attachments/RemoteStorage.swift @@ -2,7 +2,6 @@ import Foundation /// Adapter for interfacing with remote attachment storage. public protocol RemoteStorageAdapter { - /// Uploads a file to remote storage. /// /// - Parameters: @@ -13,14 +12,14 @@ public protocol RemoteStorageAdapter { fileData: Data, attachment: Attachment ) async throws - + /// Downloads a file from remote storage. /// /// - Parameter attachment: The `Attachment` describing the file to download. /// - Returns: The binary data of the downloaded file. /// - Throws: An error if the download fails or the file is not found. func downloadFile(attachment: Attachment) async throws -> Data - + /// Deletes a file from remote storage. /// /// - Parameter attachment: The `Attachment` describing the file to delete. diff --git a/Sources/PowerSync/attachments/SyncErrorHandler.swift b/Sources/PowerSync/attachments/SyncErrorHandler.swift index 268d957..b9a2b55 100644 --- a/Sources/PowerSync/attachments/SyncErrorHandler.swift +++ b/Sources/PowerSync/attachments/SyncErrorHandler.swift @@ -7,7 +7,6 @@ import Foundation /// /// If an operation fails and should not be retried, the attachment record is archived. public protocol SyncErrorHandler { - /// Handles a download error for a specific attachment. /// /// - Parameters: @@ -48,17 +47,17 @@ public protocol SyncErrorHandler { public class DefaultSyncErrorHandler: SyncErrorHandler { public init() {} - public func onDownloadError(attachment: Attachment, error: Error) async -> Bool { + public func onDownloadError(attachment _: Attachment, error _: Error) async -> Bool { // Default: do not retry failed downloads return false } - public func onUploadError(attachment: Attachment, error: Error) async -> Bool { + public func onUploadError(attachment _: Attachment, error _: Error) async -> Bool { // Default: do not retry failed uploads return false } - public func onDeleteError(attachment: Attachment, error: Error) async -> Bool { + public func onDeleteError(attachment _: Attachment, error _: Error) async -> Bool { // Default: do not retry failed deletions return false } diff --git a/Sources/PowerSync/attachments/SyncingService.swift b/Sources/PowerSync/attachments/SyncingService.swift index ace2bd7..7fae599 100644 --- a/Sources/PowerSync/attachments/SyncingService.swift +++ b/Sources/PowerSync/attachments/SyncingService.swift @@ -1,5 +1,5 @@ -import Foundation import Combine +import Foundation /// A service that synchronizes attachments between local and remote storage. /// From 48436231a371e92af7da13ebbd13d97a3e24b1c4 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 8 Apr 2025 17:48:39 +0200 Subject: [PATCH 06/36] update demo. Allow deleting attachments. --- .../project.pbxproj | 8 ++ .../Components/TodoListRow.swift | 120 +++++++++++------- .../Components/TodoListView.swift | 37 ++++-- .../Screens/PhotoPicker.swift | 79 ++++++++++++ 4 files changed, 187 insertions(+), 57 deletions(-) diff --git a/Demo/PowerSyncExample.xcodeproj/project.pbxproj b/Demo/PowerSyncExample.xcodeproj/project.pbxproj index e1035ae..8ca5d2f 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.pbxproj +++ b/Demo/PowerSyncExample.xcodeproj/project.pbxproj @@ -40,6 +40,8 @@ B69F7D862C8EE27400565448 /* AnyCodable in Frameworks */ = {isa = PBXBuildFile; productRef = B69F7D852C8EE27400565448 /* AnyCodable */; }; B6B3698A2C64F4B30033C307 /* Navigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B369892C64F4B30033C307 /* Navigation.swift */; }; B6FFD5322D06DA8000EEE60F /* PowerSync in Frameworks */ = {isa = PBXBuildFile; productRef = B6FFD5312D06DA8000EEE60F /* PowerSync */; }; + BE2F26EC2DA54B2F0080F1AE /* SupabaseRemoteStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2F26EB2DA54B2A0080F1AE /* SupabaseRemoteStorage.swift */; }; + BE2F26EE2DA555E10080F1AE /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2F26ED2DA555DD0080F1AE /* PhotoPicker.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -106,6 +108,8 @@ B6F421372BC42F450005D0D0 /* core.klib */ = {isa = PBXFileReference; lastKnownFileType = file; path = core.klib; sourceTree = ""; }; B6F4213D2BC42F5B0005D0D0 /* sqlite3.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = sqlite3.c; path = "../powersync-kotlin/core/build/interop/sqlite/sqlite3.c"; sourceTree = ""; }; B6F421402BC430B60005D0D0 /* sqlite3.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = sqlite3.h; path = "../powersync-kotlin/core/build/interop/sqlite/sqlite3.h"; sourceTree = ""; }; + BE2F26EB2DA54B2A0080F1AE /* SupabaseRemoteStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupabaseRemoteStorage.swift; sourceTree = ""; }; + BE2F26ED2DA555DD0080F1AE /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -193,6 +197,7 @@ B65C4D6B2C60D36700176007 /* Screens */ = { isa = PBXGroup; children = ( + BE2F26ED2DA555DD0080F1AE /* PhotoPicker.swift */, 6A9669032B9EE6FA00B05DCF /* SignInScreen.swift */, B65C4D6C2C60D38B00176007 /* HomeScreen.swift */, B65C4D702C60D7D800176007 /* SignUpScreen.swift */, @@ -218,6 +223,7 @@ B65C4D6F2C60D58500176007 /* PowerSync */ = { isa = PBXGroup; children = ( + BE2F26EB2DA54B2A0080F1AE /* SupabaseRemoteStorage.swift */, 6A7315BA2B98BDD30004CB17 /* SystemManager.swift */, 6A4AD3842B9EE763005CBFD4 /* SupabaseConnector.swift */, 6ABD78772B9F2D2800558A41 /* Schema.swift */, @@ -564,9 +570,11 @@ B65C4D6D2C60D38B00176007 /* HomeScreen.swift in Sources */, 6A7315882B9854220004CB17 /* PowerSyncExampleApp.swift in Sources */, B666585F2C62115300159A81 /* ListRow.swift in Sources */, + BE2F26EC2DA54B2F0080F1AE /* SupabaseRemoteStorage.swift in Sources */, B66658632C621CA700159A81 /* AddTodoListView.swift in Sources */, B666585D2C620E9E00159A81 /* WifiIcon.swift in Sources */, 6A9669042B9EE6FA00B05DCF /* SignInScreen.swift in Sources */, + BE2F26EE2DA555E10080F1AE /* PhotoPicker.swift in Sources */, 6A7315BB2B98BDD30004CB17 /* SystemManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Demo/PowerSyncExample/Components/TodoListRow.swift b/Demo/PowerSyncExample/Components/TodoListRow.swift index 4329602..edb3a62 100644 --- a/Demo/PowerSyncExample/Components/TodoListRow.swift +++ b/Demo/PowerSyncExample/Components/TodoListRow.swift @@ -1,68 +1,90 @@ import SwiftUI struct TodoListRow: View { - let todo: Todo - let completeTapped: () -> Void + let todo: Todo + let completeTapped: () -> Void + let deletePhotoTapped: () -> Void + let capturePhotoTapped: () -> Void + @State private var image: UIImage? = nil - var body: some View { - HStack { - Text(todo.description) - Group { - if (todo.photoUri == nil) { - // Nothing to display when photoURI is nil - EmptyView() - } else if let image = image { - Image(uiImage: image) - .resizable() - .scaledToFit() - } else if todo.photoUri != nil { - // Only show loading indicator if we have a URL string - ProgressView() - .onAppear { - loadImage() - }} - else { - EmptyView() - } - + var body: some View { + HStack { + Text(todo.description) + Group { + if todo.photoUri == nil { + // Nothing to display when photoURI is nil + EmptyView() + } else if let image = image { + Image(uiImage: image) + .resizable() + .scaledToFit() + } else if todo.photoUri != nil { + // Only show loading indicator if we have a URL string + ProgressView() + .onAppear { + loadImage() + } + } else { + EmptyView() + } + } + Spacer() + VStack { + if todo.photoId == nil { + Button { + capturePhotoTapped() + } label: { + Image(systemName: "camera.fill") + } + .buttonStyle(.plain) + } else { + Button { + deletePhotoTapped() + } label: { + Image(systemName: "trash.fill") + } + .buttonStyle(.plain) } - Spacer() - Button { - completeTapped() - } label: { - Image(systemName: todo.isComplete ? "checkmark.circle.fill" : "circle") - } - .buttonStyle(.plain) + Spacer() + Button { + completeTapped() + } label: { + Image(systemName: todo.isComplete ? "checkmark.circle.fill" : "circle") + } + .buttonStyle(.plain) + } + } } - } - + private func loadImage() { guard let urlString = todo.photoUri else { return } - let url = URL(fileURLWithPath: urlString) - - if let imageData = try? Data(contentsOf: url), - let loadedImage = UIImage(data: imageData) { - self.image = loadedImage + + if let imageData = try? Data(contentsOf: URL(fileURLWithPath: urlString)), + let loadedImage = UIImage(data: imageData) + { + image = loadedImage } } } - #Preview { TodoListRow( - todo: .init( - id: UUID().uuidString.lowercased(), - listId: UUID().uuidString.lowercased(), - photoId: nil, - description: "description", - isComplete: false, - createdAt: "", - completedAt: nil, - createdBy: UUID().uuidString.lowercased(), - completedBy: nil - ) + todo: .init( + id: UUID().uuidString.lowercased(), + listId: UUID().uuidString.lowercased(), + photoId: nil, + description: "description", + isComplete: false, + createdAt: "", + completedAt: nil, + createdBy: UUID().uuidString.lowercased(), + completedBy: nil, + + ), + completeTapped: {}, + deletePhotoTapped: {} ) {} } diff --git a/Demo/PowerSyncExample/Components/TodoListView.swift b/Demo/PowerSyncExample/Components/TodoListView.swift index b006cd0..f1a2a76 100644 --- a/Demo/PowerSyncExample/Components/TodoListView.swift +++ b/Demo/PowerSyncExample/Components/TodoListView.swift @@ -1,5 +1,5 @@ -import SwiftUI import IdentifiedCollections +import SwiftUI import SwiftUINavigation struct TodoListView: View { @@ -18,7 +18,7 @@ struct TodoListView: View { } IfLet($newTodo) { $newTodo in - AddTodoListView(newTodo: $newTodo, listId: listId) { result in + AddTodoListView(newTodo: $newTodo, listId: listId) { _ in withAnimation { self.newTodo = nil } @@ -26,11 +26,32 @@ struct TodoListView: View { } ForEach(todos) { todo in - TodoListRow(todo: todo) { - Task { - try await toggleCompletion(of: todo) - } - } + TodoListRow( + todo: todo, + completeTapped: { + Task { + await toggleCompletion(of: todo) + } + }, + deletePhotoTapped: { + guard let attachments = system.attachments, + let attachmentID = todo.photoId + else { + return + } + Task { + do { + _ = try await attachments.deleteFile(attachmentId: attachmentID) { tx, _ in + _ = try tx.execute(sql: "UPDATE \(TODOS_TABLE) SET photo_id = NULL WHERE id = ?", parameters: [todo.id]) + } + } catch { + self.error = error + } + } + + }, + capturePhotoTapped: {} + ) } .onDelete { indexSet in Task { @@ -42,7 +63,7 @@ struct TodoListView: View { .navigationTitle("Todos") .toolbar { ToolbarItem(placement: .primaryAction) { - if (newTodo == nil) { + if newTodo == nil { Button { withAnimation { newTodo = .init( diff --git a/Demo/PowerSyncExample/Screens/PhotoPicker.swift b/Demo/PowerSyncExample/Screens/PhotoPicker.swift index e69de29..443cc61 100644 --- a/Demo/PowerSyncExample/Screens/PhotoPicker.swift +++ b/Demo/PowerSyncExample/Screens/PhotoPicker.swift @@ -0,0 +1,79 @@ +import PhotosUI +import SwiftUI + +struct PhotoPicker: View { + @State private var selectedImage: UIImage? + @State private var imageData: Data? + @State private var showImagePicker = false + @State private var showCamera = false + + var body: some View { + VStack { + if let selectedImage = selectedImage { + Image(uiImage: selectedImage) + .resizable() + .scaledToFit() + .frame(height: 300) + + Text("Image data size: \(imageData?.count ?? 0) bytes") + } + + HStack { + Button("Camera") { + showCamera = true + } + + Button("Photo Library") { + showImagePicker = true + } + } + } + .sheet(isPresented: $showCamera) { + CameraView(image: $selectedImage, imageData: $imageData) + } + } +} + +struct CameraView: UIViewControllerRepresentable { + @Binding var image: UIImage? + @Binding var imageData: Data? + @Environment(\.presentationMode) var presentationMode + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.delegate = context.coordinator + picker.sourceType = .camera + return picker + } + + func updateUIViewController(_: UIImagePickerController, context _: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: CameraView + + init(_ parent: CameraView) { + self.parent = parent + } + + func imagePickerController(_: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let image = info[.originalImage] as? UIImage { + parent.image = image + + // Convert UIImage to Data + if let jpegData = image.jpegData(compressionQuality: 0.8) { + parent.imageData = jpegData + } + } + + parent.presentationMode.wrappedValue.dismiss() + } + + func imagePickerControllerDidCancel(_: UIImagePickerController) { + parent.presentationMode.wrappedValue.dismiss() + } + } +} From 528f8c8fbb4cb7dc45bd5c9ef6a37ea3623ba888 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 9 Apr 2025 07:29:06 +0200 Subject: [PATCH 07/36] camera --- .../Components/TodoListView.swift | 53 +++++- .../Screens/PhotoPicker.swift | 158 +++++++++--------- 2 files changed, 131 insertions(+), 80 deletions(-) diff --git a/Demo/PowerSyncExample/Components/TodoListView.swift b/Demo/PowerSyncExample/Components/TodoListView.swift index f1a2a76..78ba19b 100644 --- a/Demo/PowerSyncExample/Components/TodoListView.swift +++ b/Demo/PowerSyncExample/Components/TodoListView.swift @@ -10,6 +10,10 @@ struct TodoListView: View { @State private var error: Error? @State private var newTodo: NewTodo? @State private var editing: Bool = false + + @State private var selectedImage: UIImage? + @State private var imageData: Data? + @State private var showCamera = false var body: some View { List { @@ -50,7 +54,9 @@ struct TodoListView: View { } }, - capturePhotoTapped: {} + capturePhotoTapped: { + showCamera = true + } ) } .onDelete { indexSet in @@ -59,6 +65,9 @@ struct TodoListView: View { } } } + .sheet(isPresented: $showCamera) { + CameraView(imageData: $imageData) + } .animation(.default, value: todos) .navigationTitle("Todos") .toolbar { @@ -124,3 +133,45 @@ struct TodoListView: View { ).environment(SystemManager()) } } + + +struct CameraView: UIViewControllerRepresentable { + @Binding var imageData: Data? + @Environment(\.presentationMode) var presentationMode + + func makeUIViewController(context: Context) -> UIImagePickerController { + let picker = UIImagePickerController() + picker.delegate = context.coordinator + picker.sourceType = .camera + return picker + } + + func updateUIViewController(_: UIImagePickerController, context _: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: CameraView + + init(_ parent: CameraView) { + self.parent = parent + } + + func imagePickerController(_: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { + if let image = info[.originalImage] as? UIImage { + // Convert UIImage to Data + if let jpegData = image.jpegData(compressionQuality: 0.8) { + parent.imageData = jpegData + } + } + + parent.presentationMode.wrappedValue.dismiss() + } + + func imagePickerControllerDidCancel(_: UIImagePickerController) { + parent.presentationMode.wrappedValue.dismiss() + } + } +} diff --git a/Demo/PowerSyncExample/Screens/PhotoPicker.swift b/Demo/PowerSyncExample/Screens/PhotoPicker.swift index 443cc61..5bc8db4 100644 --- a/Demo/PowerSyncExample/Screens/PhotoPicker.swift +++ b/Demo/PowerSyncExample/Screens/PhotoPicker.swift @@ -1,79 +1,79 @@ -import PhotosUI -import SwiftUI - -struct PhotoPicker: View { - @State private var selectedImage: UIImage? - @State private var imageData: Data? - @State private var showImagePicker = false - @State private var showCamera = false - - var body: some View { - VStack { - if let selectedImage = selectedImage { - Image(uiImage: selectedImage) - .resizable() - .scaledToFit() - .frame(height: 300) - - Text("Image data size: \(imageData?.count ?? 0) bytes") - } - - HStack { - Button("Camera") { - showCamera = true - } - - Button("Photo Library") { - showImagePicker = true - } - } - } - .sheet(isPresented: $showCamera) { - CameraView(image: $selectedImage, imageData: $imageData) - } - } -} - -struct CameraView: UIViewControllerRepresentable { - @Binding var image: UIImage? - @Binding var imageData: Data? - @Environment(\.presentationMode) var presentationMode - - func makeUIViewController(context: Context) -> UIImagePickerController { - let picker = UIImagePickerController() - picker.delegate = context.coordinator - picker.sourceType = .camera - return picker - } - - func updateUIViewController(_: UIImagePickerController, context _: Context) {} - - func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { - let parent: CameraView - - init(_ parent: CameraView) { - self.parent = parent - } - - func imagePickerController(_: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { - if let image = info[.originalImage] as? UIImage { - parent.image = image - - // Convert UIImage to Data - if let jpegData = image.jpegData(compressionQuality: 0.8) { - parent.imageData = jpegData - } - } - - parent.presentationMode.wrappedValue.dismiss() - } - - func imagePickerControllerDidCancel(_: UIImagePickerController) { - parent.presentationMode.wrappedValue.dismiss() - } - } -} +//import PhotosUI +//import SwiftUI +// +//struct PhotoPicker: View { +// @State private var selectedImage: UIImage? +// @State private var imageData: Data? +// @State private var showImagePicker = false +// @State private var showCamera = false +// +// var body: some View { +// VStack { +// if let selectedImage = selectedImage { +// Image(uiImage: selectedImage) +// .resizable() +// .scaledToFit() +// .frame(height: 300) +// +// Text("Image data size: \(imageData?.count ?? 0) bytes") +// } +// +// HStack { +// Button("Camera") { +// showCamera = true +// } +// +// Button("Photo Library") { +// showImagePicker = true +// } +// } +// } +// .sheet(isPresented: $showCamera) { +// CameraView(image: $selectedImage, imageData: $imageData) +// } +// } +//} +// +//struct CameraView: UIViewControllerRepresentable { +// @Binding var image: UIImage? +// @Binding var imageData: Data? +// @Environment(\.presentationMode) var presentationMode +// +// func makeUIViewController(context: Context) -> UIImagePickerController { +// let picker = UIImagePickerController() +// picker.delegate = context.coordinator +// picker.sourceType = .camera +// return picker +// } +// +// func updateUIViewController(_: UIImagePickerController, context _: Context) {} +// +// func makeCoordinator() -> Coordinator { +// Coordinator(self) +// } +// +// class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { +// let parent: CameraView +// +// init(_ parent: CameraView) { +// self.parent = parent +// } +// +// func imagePickerController(_: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { +// if let image = info[.originalImage] as? UIImage { +// parent.image = image +// +// // Convert UIImage to Data +// if let jpegData = image.jpegData(compressionQuality: 0.8) { +// parent.imageData = jpegData +// } +// } +// +// parent.presentationMode.wrappedValue.dismiss() +// } +// +// func imagePickerControllerDidCancel(_: UIImagePickerController) { +// parent.presentationMode.wrappedValue.dismiss() +// } +// } +//} From 1c12ddfcce17ab53ad0318d2b4b6c27b1043c59d Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 14 Apr 2025 09:49:01 +0200 Subject: [PATCH 08/36] update secrets --- Demo/PowerSyncExample/_Secrets.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Demo/PowerSyncExample/_Secrets.swift b/Demo/PowerSyncExample/_Secrets.swift index 1e1b04e..7f070a8 100644 --- a/Demo/PowerSyncExample/_Secrets.swift +++ b/Demo/PowerSyncExample/_Secrets.swift @@ -4,5 +4,7 @@ import Foundation enum Secrets { static let powerSyncEndpoint = "https://your-id.powersync.journeyapps.com" static let supabaseURL = URL(string: "https://your-id.supabase.co")! - static let supabaseAnonKey = "anon-key" + static let supabaseAnonKey = "anon-key", + // Optional storage bucket name. Set to nil if you don't want to use storage. + static let supabaseStorageBucket = "media" } \ No newline at end of file From 52096f47189b2c5393033e7c4217eadb2a271169 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 14 Apr 2025 10:13:31 +0200 Subject: [PATCH 09/36] Add logger --- .../Kotlin/KotlinPowerSyncDatabaseImpl.swift | 19 ++++++++------ .../PowerSyncBackendConnectorAdapter.swift | 26 ++++++++----------- .../PowerSync/PowerSyncDatabaseProtocol.swift | 3 +++ .../attachments/AttachmentQueue.swift | 15 ++++++----- .../attachments/AttachmentsService.swift | 9 ++++--- .../attachments/SyncingService.swift | 12 +++++++-- 6 files changed, 49 insertions(+), 35 deletions(-) diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index ea78b55..486b143 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -1,27 +1,27 @@ import Foundation import PowerSyncKotlin -final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { +internal final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { + let logger: any LoggerProtocol + private let kotlinDatabase: PowerSyncKotlin.PowerSyncDatabase var currentStatus: SyncStatus { kotlinDatabase.currentStatus } + init( schema: Schema, dbFilename: String, - logger: DatabaseLogger? = nil + logger: DatabaseLogger ) { let factory = PowerSyncKotlin.DatabaseDriverFactory() kotlinDatabase = PowerSyncDatabase( factory: factory, schema: KotlinAdapter.Schema.toKotlin(schema), dbFilename: dbFilename, - logger: logger?.kLogger + logger: logger.kLogger ) - } - - init(kotlinDatabase: KotlinPowerSyncDatabase) { - self.kotlinDatabase = kotlinDatabase + self.logger = logger } func waitForFirstSync() async throws { @@ -42,7 +42,10 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { retryDelayMs: Int64 = 5000, params: [String: JsonParam?] = [:] ) async throws { - let connectorAdapter = PowerSyncBackendConnectorAdapter(swiftBackendConnector: connector) + let connectorAdapter = PowerSyncBackendConnectorAdapter( + swiftBackendConnector: connector, + db: self + ) try await kotlinDatabase.connect( connector: connectorAdapter, diff --git a/Sources/PowerSync/PowerSyncBackendConnectorAdapter.swift b/Sources/PowerSync/PowerSyncBackendConnectorAdapter.swift index 158c665..b41c2b3 100644 --- a/Sources/PowerSync/PowerSyncBackendConnectorAdapter.swift +++ b/Sources/PowerSync/PowerSyncBackendConnectorAdapter.swift @@ -1,12 +1,16 @@ import OSLog -class PowerSyncBackendConnectorAdapter: KotlinPowerSyncBackendConnector { +internal class PowerSyncBackendConnectorAdapter: KotlinPowerSyncBackendConnector { let swiftBackendConnector: PowerSyncBackendConnector - + let db: any PowerSyncDatabaseProtocol + let logTag = "PowerSyncBackendConnector" + init( - swiftBackendConnector: PowerSyncBackendConnector + swiftBackendConnector: PowerSyncBackendConnector, + db: any PowerSyncDatabaseProtocol ) { self.swiftBackendConnector = swiftBackendConnector + self.db = db } override func __fetchCredentials() async throws -> KotlinPowerSyncCredentials? { @@ -14,25 +18,17 @@ class PowerSyncBackendConnectorAdapter: KotlinPowerSyncBackendConnector { let result = try await swiftBackendConnector.fetchCredentials() return result?.kotlinCredentials } catch { - if #available(iOS 14.0, *) { - Logger().error("🔴 Failed to fetch credentials: \(error.localizedDescription)") - } else { - print("🔴 Failed to fetch credentials: \(error.localizedDescription)") - } + db.logger.error("Error while fetching credentials", tag: logTag) return nil } } override func __uploadData(database: KotlinPowerSyncDatabase) async throws { - let swiftDatabase = KotlinPowerSyncDatabaseImpl(kotlinDatabase: database) do { - return try await swiftBackendConnector.uploadData(database: swiftDatabase) + // Pass the Swift DB protocal to the connector + return try await swiftBackendConnector.uploadData(database: db) } catch { - if #available(iOS 14.0, *) { - Logger().error("🔴 Failed to upload data: \(error)") - } else { - print("🔴 Failed to upload data: \(error)") - } + db.logger.error("Error while uploading data: \(error)", tag: logTag) } } } diff --git a/Sources/PowerSync/PowerSyncDatabaseProtocol.swift b/Sources/PowerSync/PowerSyncDatabaseProtocol.swift index 9de5f00..8fa6a0e 100644 --- a/Sources/PowerSync/PowerSyncDatabaseProtocol.swift +++ b/Sources/PowerSync/PowerSyncDatabaseProtocol.swift @@ -11,6 +11,9 @@ public protocol PowerSyncDatabaseProtocol: Queries { /// The current sync status. var currentStatus: SyncStatus { get } + /// Logger used for PowerSync operations + var logger: any LoggerProtocol { get } + /// Wait for the first sync to occur func waitForFirstSync() async throws diff --git a/Sources/PowerSync/attachments/AttachmentQueue.swift b/Sources/PowerSync/attachments/AttachmentQueue.swift index efd2e15..55dad46 100644 --- a/Sources/PowerSync/attachments/AttachmentQueue.swift +++ b/Sources/PowerSync/attachments/AttachmentQueue.swift @@ -46,6 +46,8 @@ public struct WatchedAttachmentItem { public actor AttachmentQueue { /// Default name of the attachments table public static let DEFAULT_TABLE_NAME = "attachments" + + let logTag = "AttachmentQueue" /// PowerSync database client public let db: PowerSyncDatabaseProtocol @@ -86,7 +88,7 @@ public actor AttachmentQueue { /** * Logging interface used for all log operations */ -// public let logger: Logger + public let logger: any LoggerProtocol /// Attachment service for interacting with attachment records public let attachmentsService: AttachmentService @@ -102,12 +104,13 @@ public actor AttachmentQueue { remoteStorage: self.remoteStorage, localStorage: self.localStorage, attachmentsService: self.attachmentsService, + logger: self.logger, getLocalUri: { [weak self] filename in guard let self = self else { return filename } return await self.getLocalUri(filename) }, errorHandler: self.errorHandler, - syncThrottle: self.syncThrottleDuration + syncThrottle: self.syncThrottleDuration, ) /// Initializes the attachment queue @@ -125,7 +128,7 @@ public actor AttachmentQueue { syncThrottleDuration: TimeInterval = 1.0, subdirectories: [String]? = nil, downloadAttachments: Bool = true, -// logger: Logger = Logger(subsystem: "com.powersync.attachments", category: "AttachmentQueue") + logger: (any LoggerProtocol)? = nil ) { self.db = db self.remoteStorage = remoteStorage @@ -139,12 +142,12 @@ public actor AttachmentQueue { self.syncThrottleDuration = syncThrottleDuration self.subdirectories = subdirectories self.downloadAttachments = downloadAttachments -// self.logger = logger + self.logger = logger ?? db.logger attachmentsService = AttachmentService( db: db, tableName: attachmentsQueueTableName, -// logger: logger, + logger: self.logger, maxArchivedCount: archivedCacheLimit ) } @@ -193,7 +196,7 @@ public actor AttachmentQueue { try await watchTask.value } catch { if !(error is CancellationError) { -// logger.error("Error in sync job: \(error.localizedDescription)") + logger.error("Error in sync job: \(error.localizedDescription)", tag: logTag) } } } diff --git a/Sources/PowerSync/attachments/AttachmentsService.swift b/Sources/PowerSync/attachments/AttachmentsService.swift index 3fbf341..798bc61 100644 --- a/Sources/PowerSync/attachments/AttachmentsService.swift +++ b/Sources/PowerSync/attachments/AttachmentsService.swift @@ -9,7 +9,8 @@ import PowerSyncKotlin public class AttachmentService { private let db: any PowerSyncDatabaseProtocol private let tableName: String -// private let logger: Logger + private let logger: any LoggerProtocol + private let logTag = "AttachmentService" private let maxArchivedCount: Int64 /** @@ -22,12 +23,12 @@ public class AttachmentService { public init( db: PowerSyncDatabaseProtocol, tableName: String, -// logger: Logger, + logger: any LoggerProtocol, maxArchivedCount: Int64 ) { self.db = db self.tableName = tableName -// self.logger = logger + self.logger = logger self.maxArchivedCount = maxArchivedCount } @@ -145,7 +146,7 @@ public class AttachmentService { * Once a change is detected it will initiate a sync of the attachments */ public func watchActiveAttachments() throws -> AsyncThrowingStream<[String], Error> { -// logger.i("Watching attachments...") + logger.info("Watching attachments...", tag: logTag) return try db.watch( sql: """ diff --git a/Sources/PowerSync/attachments/SyncingService.swift b/Sources/PowerSync/attachments/SyncingService.swift index 7fae599..7034806 100644 --- a/Sources/PowerSync/attachments/SyncingService.swift +++ b/Sources/PowerSync/attachments/SyncingService.swift @@ -17,6 +17,9 @@ actor SyncingService { private let syncTriggerSubject = PassthroughSubject() private var periodicSyncTimer: Timer? private var syncTask: Task? + let logger: any LoggerProtocol + + let logTag = "AttachmentSync" /// Initializes a new instance of `SyncingService`. /// @@ -31,9 +34,10 @@ actor SyncingService { remoteStorage: RemoteStorageAdapter, localStorage: LocalStorageAdapter, attachmentsService: AttachmentService, + logger: any LoggerProtocol, getLocalUri: @escaping (String) async -> String, errorHandler: SyncErrorHandler? = nil, - syncThrottle: TimeInterval = 5.0 + syncThrottle: TimeInterval = 5.0, ) { self.remoteStorage = remoteStorage self.localStorage = localStorage @@ -41,6 +45,7 @@ actor SyncingService { self.getLocalUri = getLocalUri self.errorHandler = errorHandler self.syncThrottle = syncThrottle + self.logger = logger Task { await self.setupSyncFlow() } } @@ -74,7 +79,7 @@ actor SyncingService { _ = try await deleteArchivedAttachments() } catch { if error is CancellationError { break } - // logger.error("Sync failure: \(error)") + logger.error("Sync error: \(error)", tag: logTag) } } @@ -167,6 +172,7 @@ actor SyncingService { /// - Parameter attachment: The attachment to upload. /// - Returns: The updated attachment with new sync state. private func uploadAttachment(attachment: Attachment) async throws -> Attachment { + logger.info("Uploading attachment \(attachment.filename)", tag: logTag) do { guard let localUri = attachment.localUri else { throw PowerSyncError.attachmentError("No localUri for attachment \(attachment.id)") @@ -192,6 +198,7 @@ actor SyncingService { /// - Parameter attachment: The attachment to download. /// - Returns: The updated attachment with new sync state. private func downloadAttachment(attachment: Attachment) async throws -> Attachment { + logger.info("Downloading attachment \(attachment.filename)", tag: logTag) do { let attachmentPath = await getLocalUri(attachment.filename) let fileData = try await remoteStorage.downloadFile(attachment: attachment) @@ -218,6 +225,7 @@ actor SyncingService { /// - Parameter attachment: The attachment to delete. /// - Returns: The updated attachment with archived state. private func deleteAttachment(attachment: Attachment) async throws -> Attachment { + logger.info("Deleting attachment \(attachment.filename)", tag: logTag) do { try await remoteStorage.deleteFile(attachment: attachment) From aa6091799c67976a7824960ea1ef44a1684c985e Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 14 Apr 2025 10:16:34 +0200 Subject: [PATCH 10/36] remove comma --- Demo/PowerSyncExample/_Secrets.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Demo/PowerSyncExample/_Secrets.swift b/Demo/PowerSyncExample/_Secrets.swift index 7f070a8..771af3b 100644 --- a/Demo/PowerSyncExample/_Secrets.swift +++ b/Demo/PowerSyncExample/_Secrets.swift @@ -4,7 +4,7 @@ import Foundation enum Secrets { static let powerSyncEndpoint = "https://your-id.powersync.journeyapps.com" static let supabaseURL = URL(string: "https://your-id.supabase.co")! - static let supabaseAnonKey = "anon-key", + static let supabaseAnonKey = "anon-key" // Optional storage bucket name. Set to nil if you don't want to use storage. static let supabaseStorageBucket = "media" } \ No newline at end of file From 71c1ad0df53cd73c479eb29dcbe51913395dda28 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 14 Apr 2025 10:24:15 +0200 Subject: [PATCH 11/36] cleanup --- Sources/PowerSync/attachments/AttachmentQueue.swift | 2 +- Sources/PowerSync/attachments/SyncingService.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/PowerSync/attachments/AttachmentQueue.swift b/Sources/PowerSync/attachments/AttachmentQueue.swift index 55dad46..f171d32 100644 --- a/Sources/PowerSync/attachments/AttachmentQueue.swift +++ b/Sources/PowerSync/attachments/AttachmentQueue.swift @@ -110,7 +110,7 @@ public actor AttachmentQueue { return await self.getLocalUri(filename) }, errorHandler: self.errorHandler, - syncThrottle: self.syncThrottleDuration, + syncThrottle: self.syncThrottleDuration ) /// Initializes the attachment queue diff --git a/Sources/PowerSync/attachments/SyncingService.swift b/Sources/PowerSync/attachments/SyncingService.swift index 7034806..426a5b7 100644 --- a/Sources/PowerSync/attachments/SyncingService.swift +++ b/Sources/PowerSync/attachments/SyncingService.swift @@ -37,7 +37,7 @@ actor SyncingService { logger: any LoggerProtocol, getLocalUri: @escaping (String) async -> String, errorHandler: SyncErrorHandler? = nil, - syncThrottle: TimeInterval = 5.0, + syncThrottle: TimeInterval = 5.0 ) { self.remoteStorage = remoteStorage self.localStorage = localStorage From 2b361e613136b82e73d05f3ed2b14280497d45fb Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 14 Apr 2025 13:51:36 +0200 Subject: [PATCH 12/36] Add readme and photo capability. --- .../xcshareddata/xcschemes/PowerSync.xcscheme | 79 ------ .../project.pbxproj | 10 +- .../Components/TodoListView.swift | 40 ++- .../PowerSync/SystemManager.swift | 4 +- README.md | 4 + .../attachments/AttachmentQueue.swift | 6 +- Sources/PowerSync/attachments/README.md | 258 ++++++++++++++++++ 7 files changed, 306 insertions(+), 95 deletions(-) delete mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/PowerSync.xcscheme create mode 100644 Sources/PowerSync/attachments/README.md diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/PowerSync.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/PowerSync.xcscheme deleted file mode 100644 index 196f39c..0000000 --- a/.swiftpm/xcode/xcshareddata/xcschemes/PowerSync.xcscheme +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Demo/PowerSyncExample.xcodeproj/project.pbxproj b/Demo/PowerSyncExample.xcodeproj/project.pbxproj index 8ca5d2f..aedc940 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.pbxproj +++ b/Demo/PowerSyncExample.xcodeproj/project.pbxproj @@ -713,10 +713,11 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"PowerSyncExample/Preview Content\""; - DEVELOPMENT_TEAM = 6WA62GTJNA; + DEVELOPMENT_TEAM = ZGT7463CVJ; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "Take Photos for Todo Completion"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -729,7 +730,7 @@ MARKETING_VERSION = 1.0; ONLY_ACTIVE_ARCH = YES; OTHER_LDFLAGS = "$(inherited)"; - PRODUCT_BUNDLE_IDENTIFIER = com.powersync.PowerSyncExample; + PRODUCT_BUNDLE_IDENTIFIER = com.powersync.PowerSyncSwiftExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "PowerSyncExample/PowerSyncExample-Bridging-Header.h"; @@ -750,10 +751,11 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"PowerSyncExample/Preview Content\""; - DEVELOPMENT_TEAM = 6WA62GTJNA; + DEVELOPMENT_TEAM = ZGT7463CVJ; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = "Take Photos for Todo Completion"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -765,7 +767,7 @@ ); MARKETING_VERSION = 1.0; OTHER_LDFLAGS = "$(inherited)"; - PRODUCT_BUNDLE_IDENTIFIER = com.powersync.PowerSyncExample; + PRODUCT_BUNDLE_IDENTIFIER = com.powersync.PowerSyncSwiftExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "PowerSyncExample/PowerSyncExample-Bridging-Header.h"; diff --git a/Demo/PowerSyncExample/Components/TodoListView.swift b/Demo/PowerSyncExample/Components/TodoListView.swift index 78ba19b..9ac5a90 100644 --- a/Demo/PowerSyncExample/Components/TodoListView.swift +++ b/Demo/PowerSyncExample/Components/TodoListView.swift @@ -10,9 +10,10 @@ struct TodoListView: View { @State private var error: Error? @State private var newTodo: NewTodo? @State private var editing: Bool = false - + @State private var selectedImage: UIImage? - @State private var imageData: Data? + // Called when a photo has been captured. Individual widgets should register the listener + @State private var onPhotoCapture: ((_: Data) async throws -> Void)? @State private var showCamera = false var body: some View { @@ -45,7 +46,7 @@ struct TodoListView: View { } Task { do { - _ = try await attachments.deleteFile(attachmentId: attachmentID) { tx, _ in + try await attachments.deleteFile(attachmentId: attachmentID) { tx, _ in _ = try tx.execute(sql: "UPDATE \(TODOS_TABLE) SET photo_id = NULL WHERE id = ?", parameters: [todo.id]) } } catch { @@ -56,6 +57,24 @@ struct TodoListView: View { }, capturePhotoTapped: { showCamera = true + // Register a callback for successful image capture + onPhotoCapture = { (_ fileData: Data) in + guard let attachments = system.attachments + else { + return + } + + try await attachments.saveFile( + data: fileData, + mediaType: "image/jpeg", + fileExtension: "jpg" + ) { tx, record in + _ = try tx.execute( + sql: "UPDATE \(TODOS_TABLE) SET photo_id = ? WHERE id = ?", + parameters: [record.id, todo.id] + ) + } + } } ) } @@ -66,7 +85,7 @@ struct TodoListView: View { } } .sheet(isPresented: $showCamera) { - CameraView(imageData: $imageData) + CameraView(onPhotoCapture: $onPhotoCapture) } .animation(.default, value: todos) .navigationTitle("Todos") @@ -134,9 +153,8 @@ struct TodoListView: View { } } - struct CameraView: UIViewControllerRepresentable { - @Binding var imageData: Data? + @Binding var onPhotoCapture: ((_: Data) async throws -> Void)? @Environment(\.presentationMode) var presentationMode func makeUIViewController(context: Context) -> UIImagePickerController { @@ -163,7 +181,15 @@ struct CameraView: UIViewControllerRepresentable { if let image = info[.originalImage] as? UIImage { // Convert UIImage to Data if let jpegData = image.jpegData(compressionQuality: 0.8) { - parent.imageData = jpegData + if let photoCapture = parent.onPhotoCapture { + Task { + do { + try await photoCapture(jpegData) + } catch { + print("Error saving photo: \(error)") + } + } + } } } diff --git a/Demo/PowerSyncExample/PowerSync/SystemManager.swift b/Demo/PowerSyncExample/PowerSync/SystemManager.swift index eb922b5..12fed6d 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -8,9 +8,7 @@ func getAttachmentsDirectoryPath() throws -> String { ).first else { throw PowerSyncError.attachmentError("Could not determine attachments directory path") } - let r = documentsURL.appendingPathComponent("attachments").path - - return r + return documentsURL.appendingPathComponent("attachments").path } @Observable diff --git a/README.md b/README.md index 682258c..8e938f6 100644 --- a/README.md +++ b/README.md @@ -83,3 +83,7 @@ The PowerSync Swift SDK currently makes use of the [PowerSync Kotlin Multiplatfo ## Migration from Alpha to Beta See these [developer notes](https://docs.powersync.com/client-sdk-references/swift#migrating-from-the-alpha-to-the-beta-sdk) if you are migrating from the alpha to the beta version of the Swift SDK. + +## Attachments + +See the attachments [README](./Sources/PowerSync/attachments/README.md) for more information. diff --git a/Sources/PowerSync/attachments/AttachmentQueue.swift b/Sources/PowerSync/attachments/AttachmentQueue.swift index f171d32..4fe90d6 100644 --- a/Sources/PowerSync/attachments/AttachmentQueue.swift +++ b/Sources/PowerSync/attachments/AttachmentQueue.swift @@ -46,7 +46,7 @@ public struct WatchedAttachmentItem { public actor AttachmentQueue { /// Default name of the attachments table public static let DEFAULT_TABLE_NAME = "attachments" - + let logTag = "AttachmentQueue" /// PowerSync database client @@ -304,6 +304,7 @@ public actor AttachmentQueue { /// - fileExtension: File extension /// - updateHook: Hook to assign attachment relationships in the same transaction /// - Returns: The created attachment + @discardableResult public func saveFile( data: Data, mediaType: String, @@ -343,6 +344,7 @@ public actor AttachmentQueue { /// - Parameters: /// - attachmentId: ID of the attachment to delete /// - updateHook: Hook to perform additional DB updates in the same transaction + @discardableResult public func deleteFile( attachmentId: String, updateHook: @escaping (ConnectionContext, Attachment) throws -> Void @@ -361,7 +363,7 @@ public actor AttachmentQueue { hasSynced: attachment.hasSynced, localUri: attachment.localUri, mediaType: attachment.mediaType, - size: attachment.size, + size: attachment.size ) return try self.attachmentsService.upsertAttachment(updatedAttachment, context: tx) diff --git a/Sources/PowerSync/attachments/README.md b/Sources/PowerSync/attachments/README.md new file mode 100644 index 0000000..f186350 --- /dev/null +++ b/Sources/PowerSync/attachments/README.md @@ -0,0 +1,258 @@ +# PowerSync Attachment Helpers + +A [PowerSync](https://powersync.com) library to manage attachments in Swift apps. + +This package is included in the PowerSync Core module. + +## Alpha Release + +Attachment helpers are currently in an alpha state, intended strictly for testing. Expect breaking changes and instability as development continues. + +Do not rely on this package for production use. + +## Usage + +An `AttachmentQueue` is used to manage and sync attachments in your app. The attachments' state is stored in a local-only attachments table. + +### Key Assumptions + +- Each attachment should be identifiable by a unique ID. +- Attachments are immutable. +- Relational data should contain a foreign key column that references the attachment ID. +- Relational data should reflect the holistic state of attachments at any given time. An existing local attachment will be deleted locally if no relational data references it. + +### Example + +In this example, the user captures photos when checklist items are completed as part of an inspection workflow. + +The schema for the `checklist` table: + +```swift +let checklists = Table( + name: "checklists", + columns: [ + Column.text("description"), + Column.integer("completed"), + Column.text("photo_id"), + ] +) + +let schema = Schema( + tables: [ + checklists, + createAttachmentsTable(name: "attachments") // Includes the table which stores attachment states + ] +) +``` + +The `createAttachmentsTable` function defines the local-only attachment state storage table. + +An attachments table definition can be created with the following options: + +| Option | Description | Default | +| ------ | --------------------- | ------------- | +| `name` | The name of the table | `attachments` | + +The default columns in `AttachmentTable`: + +| Column Name | Type | Description | +| ------------ | --------- | ------------------------------------------------------------------------------------------------------------------ | +| `id` | `TEXT` | The ID of the attachment record | +| `filename` | `TEXT` | The filename of the attachment | +| `media_type` | `TEXT` | The media type of the attachment | +| `state` | `INTEGER` | The state of the attachment, one of `AttachmentState` enum values | +| `timestamp` | `INTEGER` | The timestamp of the last update to the attachment record | +| `size` | `INTEGER` | The size of the attachment in bytes | +| `has_synced` | `INTEGER` | Internal tracker which tracks if the attachment has ever been synced. This is used for caching/archiving purposes. | +| `meta_data` | `TEXT` | Any extra meta data for the attachment. JSON is usually a good choice. | + +### Steps to Implement + +1. Implement a `RemoteStorageAdapter` which interfaces with a remote storage provider. This will be used for downloading, uploading, and deleting attachments. + +```swift +class RemoteStorage: RemoteStorageAdapter { + func uploadFile(data: Data, attachment: Attachment) async throws { + // TODO: Make a request to the backend + } + + func downloadFile(attachment: Attachment) async throws -> Data { + // TODO: Make a request to the backend + } + + func deleteFile(attachment: Attachment) async throws { + // TODO: Make a request to the backend + } +} +``` + +2. Create an instance of `AttachmentQueue`. This class provides default syncing utilities and implements a default sync strategy. It can be subclassed for custom functionality. + +```swift +func getAttachmentsDirectoryPath() throws -> String { + guard let documentsURL = FileManager.default.urls( + for: .documentDirectory, + in: .userDomainMask + ).first else { + throw PowerSyncError.attachmentError("Could not determine attachments directory path") + } + return documentsURL.appendingPathComponent("attachments").path +} + +let queue = AttachmentQueue( + db: db, + attachmentsDirectory: try getAttachmentsDirectoryPath(), + remoteStorage: RemoteStorage(), + watchedAttachments: try db.watch( + options: WatchOptions( + sql: "SELECT photo_id FROM checklists WHERE photo_id IS NOT NULL", + parameters: [], + mapper: { cursor in + try WatchedAttachmentItem( + id: cursor.getString(name: "photo_id"), + fileExtension: "jpg" + ) + } + ) + ) +) +``` + +- The `attachmentsDirectory` specifies where local attachment files should be stored. `FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("attachments")` is a good choice. +- The `remoteStorage` is responsible for connecting to the attachments backend. See the `RemoteStorageAdapter` protocol definition. +- `watchedAttachments` is a publisher of `WatchedAttachmentItem`. These items represent the attachments that should be present in the application. + +3. Call `startSync()` to start syncing attachments. + +```swift +queue.startSync() +``` + +4. To create an attachment and add it to the queue, call `saveFile()`. This method saves the file to local storage, creates an attachment record, queues the file for upload, and allows assigning the newly created attachment ID to a checklist item. + +```swift +try await queue.saveFile( + data: Data(), // The attachment's data + mediaType: "image/jpg", + fileExtension: "jpg" +) { tx, attachment in + // Assign the attachment ID to a checklist item in the same transaction + try tx.execute( + sql: """ + UPDATE + checklists + SET + photo_id = ? + WHERE + id = ? + """, + arguments: [attachment.id, checklistId] + ) +} +``` + +#### Handling Errors + +The attachment queue automatically retries failed sync operations. Retries continue indefinitely until success. A `SyncErrorHandler` can be provided to the `AttachmentQueue` constructor. This handler provides methods invoked on a remote sync exception. The handler can return a Boolean indicating if the attachment sync should be retried or archived. + +```swift +class ErrorHandler: SyncErrorHandler { + func onDownloadError(attachment: Attachment, error: Error) async -> Bool { + // TODO: Return if the attachment sync should be retried + } + + func onUploadError(attachment: Attachment, error: Error) async -> Bool { + // TODO: Return if the attachment sync should be retried + } + + func onDeleteError(attachment: Attachment, error: Error) async -> Bool { + // TODO: Return if the attachment sync should be retried + } +} + +// Pass the handler to the queue constructor +let queue = AttachmentQueue( + db: db, + attachmentsDirectory: attachmentsDirectory, + remoteStorage: remoteStorage, + errorHandler: ErrorHandler() +) +``` + +# Implementation Details + +## Attachment State + +The `AttachmentQueue` class manages attachments in your app by tracking their state. + +The state of an attachment can be one of the following: + +| State | Description | +| ----------------- | ------------------------------------------------------------------------------ | +| `QUEUED_UPLOAD` | The attachment has been queued for upload to the cloud storage | +| `QUEUED_DELETE` | The attachment has been queued for delete in the cloud storage (and locally) | +| `QUEUED_DOWNLOAD` | The attachment has been queued for download from the cloud storage | +| `SYNCED` | The attachment has been synced | +| `ARCHIVED` | The attachment has been orphaned, i.e., the associated record has been deleted | + +## Syncing Attachments + +The `AttachmentQueue` sets a watched query on the `attachments` table for records in the `QUEUED_UPLOAD`, `QUEUED_DELETE`, and `QUEUED_DOWNLOAD` states. An event loop triggers calls to the remote storage for these operations. + +In addition to watching for changes, the `AttachmentQueue` also triggers a sync periodically. This will retry any failed uploads/downloads, particularly after the app was offline. By default, this is every 30 seconds but can be configured by setting `syncInterval` in the `AttachmentQueue` constructor options or disabled by setting the interval to `0`. + +### Watching State + +The `watchedAttachments` publisher provided to the `AttachmentQueue` constructor is used to reconcile the local attachment state. Each emission of the publisher should represent the current attachment state. The updated state is constantly compared to the current queue state. Items are queued based on the difference. + +- A new watched item not present in the current queue is treated as an upstream attachment creation that needs to be downloaded. + - An attachment record is created using the provided watched item. The filename will be inferred using a default filename resolver if it has not been provided in the watched item. + - The syncing service will attempt to download the attachment from the remote storage. + - The attachment will be saved to the local filesystem. The `localURI` on the attachment record will be updated. + - The attachment state will be updated to `SYNCED`. +- Local attachments are archived if the watched state no longer includes the item. Archived items are cached and can be restored if the watched state includes them in the future. The number of cached items is defined by the `archivedCacheLimit` parameter in the `AttachmentQueue` constructor. Items are deleted once the cache limit is reached. + +### Uploading + +The `saveFile` method provides a simple method for creating attachments that should be uploaded to the backend. This method accepts the raw file content and metadata. This function: + +- Persists the attachment to the local filesystem. +- Creates an attachment record linked to the local attachment file. +- Queues the attachment for upload. +- Allows assigning the attachment to relational data. + +The sync process after calling `saveFile` is: + +- An `AttachmentRecord` is created or updated with a state of `QUEUED_UPLOAD`. +- The `RemoteStorageAdapter` `uploadFile` function is called with the `Attachment` record. +- The `AttachmentQueue` picks this up and, upon successful upload to the remote storage, sets the state to `SYNCED`. +- If the upload is not successful, the record remains in the `QUEUED_UPLOAD` state, and uploading will be retried when syncing triggers again. Retries can be stopped by providing an `errorHandler`. + +### Downloading + +Attachments are scheduled for download when the `watchedAttachments` publisher emits a `WatchedAttachmentItem` not present in the queue. + +- An `AttachmentRecord` is created or updated with the `QUEUED_DOWNLOAD` state. +- The `RemoteStorageAdapter` `downloadFile` function is called with the attachment record. +- The received data is persisted to the local filesystem. +- If this is successful, update the `AttachmentRecord` state to `SYNCED`. +- If any of these fail, the download is retried in the next sync trigger. + +### Deleting Attachments + +Local attachments are archived and deleted (locally) if the `watchedAttachments` publisher no longer references them. Archived attachments are deleted locally after cache invalidation. + +In some cases, users might want to explicitly delete an attachment in the backend. The `deleteFile` function provides a mechanism for this. This function: + +- Deletes the attachment on the local filesystem. +- Updates the record to the `QUEUED_DELETE` state. +- Allows removing assignments to relational data. + +### Expire Cache + +When PowerSync removes a record, as a result of coming back online or conflict resolution, for instance: + +- Any associated `AttachmentRecord` is orphaned. +- On the next sync trigger, the `AttachmentQueue` sets all orphaned records to the `ARCHIVED` state. +- By default, the `AttachmentQueue` only keeps the last `100` attachment records and then expires the rest. +- In some cases, these records (attachment IDs) might be restored. An archived attachment will be restored if it is still in the cache. This can be configured by setting `cacheLimit` in the `AttachmentQueue` constructor options. From 0a81d6448b29dd347ce90c6faf8d76d257606514 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 14 Apr 2025 14:45:56 +0200 Subject: [PATCH 13/36] Allow gallery image picker --- .../Components/TodoListRow.swift | 22 ++++-- .../Components/TodoListView.swift | 75 ++++++++++++------- .../attachments/AttachmentsService.swift | 4 +- .../attachments/AttachmentsTable.swift | 7 +- 4 files changed, 71 insertions(+), 37 deletions(-) diff --git a/Demo/PowerSyncExample/Components/TodoListRow.swift b/Demo/PowerSyncExample/Components/TodoListRow.swift index edb3a62..2ab5124 100644 --- a/Demo/PowerSyncExample/Components/TodoListRow.swift +++ b/Demo/PowerSyncExample/Components/TodoListRow.swift @@ -5,6 +5,7 @@ struct TodoListRow: View { let completeTapped: () -> Void let deletePhotoTapped: () -> Void let capturePhotoTapped: () -> Void + let selectPhotoTapped: () -> Void @State private var image: UIImage? = nil @@ -32,12 +33,20 @@ struct TodoListRow: View { Spacer() VStack { if todo.photoId == nil { - Button { - capturePhotoTapped() - } label: { - Image(systemName: "camera.fill") + HStack { + Button { + capturePhotoTapped() + } label: { + Image(systemName: "camera.fill") + } + .buttonStyle(.plain) + Button { + selectPhotoTapped() + } label: { + Image(systemName: "photo.on.rectangle") + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } else { Button { deletePhotoTapped() @@ -85,6 +94,7 @@ struct TodoListRow: View { ), completeTapped: {}, - deletePhotoTapped: {} + deletePhotoTapped: {}, + capturePhotoTapped: {} ) {} } diff --git a/Demo/PowerSyncExample/Components/TodoListView.swift b/Demo/PowerSyncExample/Components/TodoListView.swift index 9ac5a90..2f1af65 100644 --- a/Demo/PowerSyncExample/Components/TodoListView.swift +++ b/Demo/PowerSyncExample/Components/TodoListView.swift @@ -11,10 +11,10 @@ struct TodoListView: View { @State private var newTodo: NewTodo? @State private var editing: Bool = false - @State private var selectedImage: UIImage? // Called when a photo has been captured. Individual widgets should register the listener - @State private var onPhotoCapture: ((_: Data) async throws -> Void)? - @State private var showCamera = false + @State private var onMediaSelect: ((_: Data) async throws -> Void)? + @State private var pickMediaType: UIImagePickerController.SourceType = .camera + @State private var showMediaPicker = false var body: some View { List { @@ -56,25 +56,14 @@ struct TodoListView: View { }, capturePhotoTapped: { - showCamera = true - // Register a callback for successful image capture - onPhotoCapture = { (_ fileData: Data) in - guard let attachments = system.attachments - else { - return - } - - try await attachments.saveFile( - data: fileData, - mediaType: "image/jpeg", - fileExtension: "jpg" - ) { tx, record in - _ = try tx.execute( - sql: "UPDATE \(TODOS_TABLE) SET photo_id = ? WHERE id = ?", - parameters: [record.id, todo.id] - ) - } - } + registerMediaCallback(todo: todo) + pickMediaType = .camera + showMediaPicker = true + }, + selectPhotoTapped: { + registerMediaCallback(todo: todo) + pickMediaType = .photoLibrary + showMediaPicker = true } ) } @@ -84,8 +73,11 @@ struct TodoListView: View { } } } - .sheet(isPresented: $showCamera) { - CameraView(onPhotoCapture: $onPhotoCapture) + .sheet(isPresented: $showMediaPicker) { + CameraView( + onMediaSelect: $onMediaSelect, + mediaType: $pickMediaType + ) } .animation(.default, value: todos) .navigationTitle("Todos") @@ -143,6 +135,32 @@ struct TodoListView: View { self.error = error } } + + /// Registers a callback which saves a photo for the specified Todo item if media is sucessfully loaded. + func registerMediaCallback(todo: Todo) { + // Register a callback for successful image capture + onMediaSelect = { (_ fileData: Data) in + guard let attachments = system.attachments + else { + return + } + + do { + try await attachments.saveFile( + data: fileData, + mediaType: "image/jpeg", + fileExtension: "jpg" + ) { tx, record in + _ = try tx.execute( + sql: "UPDATE \(TODOS_TABLE) SET photo_id = ? WHERE id = ?", + parameters: [record.id, todo.id] + ) + } + } catch { + self.error = error + } + } + } } #Preview { @@ -154,13 +172,15 @@ struct TodoListView: View { } struct CameraView: UIViewControllerRepresentable { - @Binding var onPhotoCapture: ((_: Data) async throws -> Void)? + @Binding var onMediaSelect: ((_: Data) async throws -> Void)? + @Binding var mediaType: UIImagePickerController.SourceType + @Environment(\.presentationMode) var presentationMode func makeUIViewController(context: Context) -> UIImagePickerController { let picker = UIImagePickerController() picker.delegate = context.coordinator - picker.sourceType = .camera + picker.sourceType = mediaType return picker } @@ -181,11 +201,12 @@ struct CameraView: UIViewControllerRepresentable { if let image = info[.originalImage] as? UIImage { // Convert UIImage to Data if let jpegData = image.jpegData(compressionQuality: 0.8) { - if let photoCapture = parent.onPhotoCapture { + if let photoCapture = parent.onMediaSelect { Task { do { try await photoCapture(jpegData) } catch { + // The photoCapture method should handle errors print("Error saving photo: \(error)") } } diff --git a/Sources/PowerSync/attachments/AttachmentsService.swift b/Sources/PowerSync/attachments/AttachmentsService.swift index 798bc61..a80f687 100644 --- a/Sources/PowerSync/attachments/AttachmentsService.swift +++ b/Sources/PowerSync/attachments/AttachmentsService.swift @@ -238,7 +238,7 @@ public class AttachmentService { hasSynced: attachment.hasSynced, localUri: attachment.localUri, mediaType: attachment.mediaType, - size: attachment.size, + size: attachment.size ) try context.execute( @@ -256,7 +256,7 @@ public class AttachmentService { updatedRecord.mediaType ?? NSNull(), updatedRecord.size ?? NSNull(), updatedRecord.state, - updatedRecord.hasSynced ?? 0, + updatedRecord.hasSynced ?? 0 ] ) diff --git a/Sources/PowerSync/attachments/AttachmentsTable.swift b/Sources/PowerSync/attachments/AttachmentsTable.swift index 1e4ea08..ae5cae9 100644 --- a/Sources/PowerSync/attachments/AttachmentsTable.swift +++ b/Sources/PowerSync/attachments/AttachmentsTable.swift @@ -1,6 +1,8 @@ +/// Creates a PowerSync Schema table for attachment state public func createAttachmentsTable(name: String) -> Table { return Table( - name: name, columns: [ + name: name, + columns: [ .integer("timestamp"), .integer("state"), .text("filename"), @@ -9,6 +11,7 @@ public func createAttachmentsTable(name: String) -> Table { .text("media_type"), .integer("size"), .text("meta_data"), - ], localOnly: true + ], + localOnly: true ) } From ec1eba105d269637cee354e49ce47dba6a7b1fed Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Mon, 14 Apr 2025 14:53:34 +0200 Subject: [PATCH 14/36] fix test --- Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift b/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift index b8ae92b..26fa833 100644 --- a/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift +++ b/Tests/PowerSyncTests/Kotlin/SqlCursorTests.swift @@ -33,7 +33,8 @@ final class SqlCursorTests: XCTestCase { database = KotlinPowerSyncDatabaseImpl( schema: schema, - dbFilename: ":memory:" + dbFilename: ":memory:", + logger: DatabaseLogger(DefaultLogger()) ) try await database.disconnectAndClear() } From f5e791df910b085e601d3429263328e6fd0171e6 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 15 Apr 2025 10:37:57 +0200 Subject: [PATCH 15/36] improve locking for attachment syncing --- .../Components/TodoListView.swift | 10 +- Demo/PowerSyncExample/PowerSync/Schema.swift | 6 +- .../PowerSync/SystemManager.swift | 39 +++- Sources/PowerSync/Kotlin/KotlinTypes.swift | 2 + Sources/PowerSync/QueriesProtocol.swift | 5 +- ...sService.swift => AttachmentContext.swift} | 103 +++------ .../attachments/AttachmentQueue.swift | 213 ++++++++++-------- .../attachments/AttachmentService.swift | 94 ++++++++ ...mentsTable.swift => AttachmentTable.swift} | 2 +- Sources/PowerSync/attachments/README.md | 4 +- .../attachments/SyncingService.swift | 18 +- .../Kotlin/AttachmentTests.swift | 2 +- .../KotlinPowerSyncDatabaseImplTests.swift | 3 +- 13 files changed, 294 insertions(+), 207 deletions(-) rename Sources/PowerSync/attachments/{AttachmentsService.swift => AttachmentContext.swift} (71%) create mode 100644 Sources/PowerSync/attachments/AttachmentService.swift rename Sources/PowerSync/attachments/{AttachmentsTable.swift => AttachmentTable.swift} (87%) diff --git a/Demo/PowerSyncExample/Components/TodoListView.swift b/Demo/PowerSyncExample/Components/TodoListView.swift index 2f1af65..ccf042f 100644 --- a/Demo/PowerSyncExample/Components/TodoListView.swift +++ b/Demo/PowerSyncExample/Components/TodoListView.swift @@ -69,7 +69,9 @@ struct TodoListView: View { } .onDelete { indexSet in Task { - await delete(at: indexSet) + if let toDelete = indexSet.map({ todos[$0] }).first { + await delete(todo: toDelete) + } } } } @@ -124,12 +126,10 @@ struct TodoListView: View { } } - func delete(at offset: IndexSet) async { + func delete(todo: Todo) async { do { error = nil - let todosToDelete = offset.map { todos[$0] } - - try await system.deleteTodo(id: todosToDelete[0].id) + try await system.deleteTodo(todo: todo) } catch { self.error = error diff --git a/Demo/PowerSyncExample/PowerSync/Schema.swift b/Demo/PowerSyncExample/PowerSync/Schema.swift index 539e165..34fddf2 100644 --- a/Demo/PowerSyncExample/PowerSync/Schema.swift +++ b/Demo/PowerSyncExample/PowerSync/Schema.swift @@ -36,4 +36,8 @@ let todos = Table( ] ) -let AppSchema = Schema(lists, todos, createAttachmentsTable(name: "attachments")) +let AppSchema = Schema( + lists, + todos, + createAttachmentTable(name: "attachments") +) diff --git a/Demo/PowerSyncExample/PowerSync/SystemManager.swift b/Demo/PowerSyncExample/PowerSync/SystemManager.swift index 12fed6d..50edaba 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -79,7 +79,7 @@ class SystemManager { func watchLists(_ callback: @escaping (_ lists: [ListContent]) -> Void) async { do { - for try await lists in try db.watch( + for try await lists in try db.watch( options: WatchOptions( sql: "SELECT * FROM \(LISTS_TABLE)", mapper: { cursor in @@ -100,7 +100,7 @@ class SystemManager { } func insertList(_ list: NewListContent) async throws { - let result = try await db.execute( + _ = try await db.execute( sql: "INSERT INTO \(LISTS_TABLE) (id, created_at, name, owner_id) VALUES (uuid(), datetime(), ?, ?)", parameters: [list.name, connector.currentUserID] ) @@ -112,6 +112,9 @@ class SystemManager { sql: "DELETE FROM \(LISTS_TABLE) WHERE id = ?", parameters: [id] ) + + // Attachments linked to these will be archived and deleted eventually + // Attachments should be deleted explicitly if required _ = try transaction.execute( sql: "DELETE FROM \(TODOS_TABLE) WHERE list_id = ?", parameters: [id] @@ -177,12 +180,30 @@ class SystemManager { } } - func deleteTodo(id: String) async throws { - _ = try await db.writeTransaction(callback: { transaction in - try transaction.execute( - sql: "DELETE FROM \(TODOS_TABLE) WHERE id = ?", - parameters: [id] - ) - }) + func deleteTodo(todo: Todo) async throws { + if let attachments, let photoId = todo.photoId { + try await attachments.deleteFile( + attachmentId: photoId + ) { (tx, _) in + try self.deleteTodoInTX( + id: todo.id, + tx: tx + ) + } + } else { + try await db.writeTransaction { tx in + try self.deleteTodoInTX( + id: todo.id, + tx: tx + ) + } + } + } + + func deleteTodoInTX(id: String, tx: ConnectionContext) throws { + _ = try tx.execute( + sql: "DELETE FROM \(TODOS_TABLE) WHERE id = ?", + parameters: [id] + ) } } diff --git a/Sources/PowerSync/Kotlin/KotlinTypes.swift b/Sources/PowerSync/Kotlin/KotlinTypes.swift index ffb47a9..23c361f 100644 --- a/Sources/PowerSync/Kotlin/KotlinTypes.swift +++ b/Sources/PowerSync/Kotlin/KotlinTypes.swift @@ -9,3 +9,5 @@ public typealias JsonParam = PowerSyncKotlin.JsonParam public typealias CrudTransaction = PowerSyncKotlin.CrudTransaction typealias KotlinPowerSyncCredentials = PowerSyncKotlin.PowerSyncCredentials typealias KotlinPowerSyncDatabase = PowerSyncKotlin.PowerSyncDatabase +public typealias Transaction = PowerSyncKotlin.PowerSyncTransaction +public typealias ConnectionContext = PowerSyncKotlin.ConnectionContext diff --git a/Sources/PowerSync/QueriesProtocol.swift b/Sources/PowerSync/QueriesProtocol.swift index 0f1bbab..755707f 100644 --- a/Sources/PowerSync/QueriesProtocol.swift +++ b/Sources/PowerSync/QueriesProtocol.swift @@ -1,6 +1,5 @@ import Combine import Foundation -import PowerSyncKotlin public let DEFAULT_WATCH_THROTTLE_MS = Int64(30) @@ -90,10 +89,10 @@ public protocol Queries { ) throws -> AsyncThrowingStream<[RowType], Error> /// Execute a write transaction with the given callback - func writeTransaction(callback: @escaping (any PowerSyncTransaction) throws -> R) async throws -> R + func writeTransaction(callback: @escaping (any Transaction) throws -> R) async throws -> R /// Execute a read transaction with the given callback - func readTransaction(callback: @escaping (any PowerSyncTransaction) throws -> R) async throws -> R + func readTransaction(callback: @escaping (any Transaction) throws -> R) async throws -> R } public extension Queries { diff --git a/Sources/PowerSync/attachments/AttachmentsService.swift b/Sources/PowerSync/attachments/AttachmentContext.swift similarity index 71% rename from Sources/PowerSync/attachments/AttachmentsService.swift rename to Sources/PowerSync/attachments/AttachmentContext.swift index a80f687..9a61cca 100644 --- a/Sources/PowerSync/attachments/AttachmentsService.swift +++ b/Sources/PowerSync/attachments/AttachmentContext.swift @@ -1,25 +1,19 @@ import Foundation -// TODO: should not need this -import PowerSyncKotlin - -/** - * Service for interacting with the local attachment records. - */ -public class AttachmentService { +/// Context which performs actions on the attachment records +public class AttachmentContext { private let db: any PowerSyncDatabaseProtocol private let tableName: String private let logger: any LoggerProtocol private let logTag = "AttachmentService" private let maxArchivedCount: Int64 - /** - * Table used for storing attachments in the attachment queue. - */ + /// Table used for storing attachments in the attachment queue. private var table: String { return tableName } + /// Initializes a new `AttachmentContext`. public init( db: PowerSyncDatabaseProtocol, tableName: String, @@ -32,16 +26,12 @@ public class AttachmentService { self.maxArchivedCount = maxArchivedCount } - /** - * Delete the attachment from the attachment queue. - */ + /// Deletes the attachment from the attachment queue. public func deleteAttachment(id: String) async throws { _ = try await db.execute(sql: "DELETE FROM \(table) WHERE id = ?", parameters: [id]) } - /** - * Set the state of the attachment to ignore. - */ + /// Sets the state of the attachment to ignored (archived). public func ignoreAttachment(id: String) async throws { _ = try await db.execute( sql: "UPDATE \(table) SET state = ? WHERE id = ?", @@ -49,27 +39,21 @@ public class AttachmentService { ) } - /** - * Get the attachment from the attachment queue using an ID. - */ + /// Gets the attachment from the attachment queue using an ID. public func getAttachment(id: String) async throws -> Attachment? { return try await db.getOptional(sql: "SELECT * FROM \(table) WHERE id = ?", parameters: [id], mapper: { cursor in try Attachment.fromCursor(cursor) }) } - /** - * Save the attachment to the attachment queue. - */ + /// Saves the attachment to the attachment queue. public func saveAttachment(attachment: Attachment) async throws -> Attachment { return try await db.writeTransaction { ctx in try self.upsertAttachment(attachment, context: ctx) } } - /** - * Save the attachments to the attachment queue. - */ + /// Saves multiple attachments to the attachment queue. public func saveAttachments(attachments: [Attachment]) async throws { if attachments.isEmpty { return @@ -82,9 +66,7 @@ public class AttachmentService { } } - /** - * Get all the ID's of attachments in the attachment queue. - */ + /// Gets all the IDs of attachments in the attachment queue. public func getAttachmentIds() async throws -> [String] { return try await db.getAll( sql: "SELECT id FROM \(table) WHERE id IS NOT NULL", @@ -95,6 +77,7 @@ public class AttachmentService { ) } + /// Gets all attachments in the attachment queue. public func getAttachments() async throws -> [Attachment] { return try await db.getAll( sql: """ @@ -114,9 +97,7 @@ public class AttachmentService { ) } - /** - * Gets all the active attachments which require an operation to be performed. - */ + /// Gets all active attachments that require an operation to be performed. public func getActiveAttachments() async throws -> [Attachment] { return try await db.getAll( sql: """ @@ -141,52 +122,18 @@ public class AttachmentService { } } - /** - * Watcher for changes to attachments table. - * Once a change is detected it will initiate a sync of the attachments - */ - public func watchActiveAttachments() throws -> AsyncThrowingStream<[String], Error> { - logger.info("Watching attachments...", tag: logTag) - - return try db.watch( - sql: """ - SELECT - id - FROM - \(table) - WHERE - state = ? - OR state = ? - OR state = ? - ORDER BY - timestamp ASC - """, - parameters: [ - AttachmentState.queuedUpload.rawValue, - AttachmentState.queuedDownload.rawValue, - AttachmentState.queuedDelete.rawValue, - ] - ) { cursor in - try cursor.getString(name: "id") - } - } - - /** - * Helper function to clear the attachment queue - * Currently only used for testing purposes. - */ + /// Clears the attachment queue. + /// + /// - Note: Currently only used for testing purposes. public func clearQueue() async throws { - // logger.i("Clearing attachment queue...") _ = try await db.execute("DELETE FROM \(table)") } - /** - * Delete attachments which have been archived - * @returns true if all items have been deleted. Returns false if there might be more archived - * items remaining. - */ + /// Deletes attachments that have been archived. + /// + /// - Parameter callback: A callback invoked with the list of archived attachments before deletion. + /// - Returns: `true` if all items have been deleted, `false` if there may be more archived items remaining. public func deleteArchivedAttachments(callback: @escaping ([Attachment]) async throws -> Void) async throws -> Bool { - // First fetch the attachments in order to allow other cleanup let limit = 1000 let attachments = try await db.getAll( sql: """ @@ -222,12 +169,15 @@ public class AttachmentService { return attachments.count < limit } - /** - * Upserts an attachment record synchronously given a database connection context. - */ + /// Upserts an attachment record synchronously using a database transaction context. + /// + /// - Parameters: + /// - attachment: The attachment to upsert. + /// - context: The database transaction context. + /// - Returns: The original attachment. public func upsertAttachment( _ attachment: Attachment, - context: PowerSyncTransaction + context: ConnectionContext ) throws -> Attachment { let timestamp = Int(Date().timeIntervalSince1970 * 1000) let updatedRecord = Attachment( @@ -263,3 +213,4 @@ public class AttachmentService { return attachment } } + diff --git a/Sources/PowerSync/attachments/AttachmentQueue.swift b/Sources/PowerSync/attachments/AttachmentQueue.swift index 4fe90d6..b9261fd 100644 --- a/Sources/PowerSync/attachments/AttachmentQueue.swift +++ b/Sources/PowerSync/attachments/AttachmentQueue.swift @@ -2,9 +2,6 @@ import Combine import Foundation import OSLog -// TODO: should not need this -import PowerSyncKotlin - /// A watched attachment record item. /// This is usually returned from watching all relevant attachment IDs. public struct WatchedAttachmentItem { @@ -45,7 +42,7 @@ public struct WatchedAttachmentItem { /// Requires a PowerSyncDatabase, a RemoteStorageAdapter implementation, and a directory name for attachments. public actor AttachmentQueue { /// Default name of the attachments table - public static let DEFAULT_TABLE_NAME = "attachments" + public static let defaultTableName = "attachments" let logTag = "AttachmentQueue" @@ -121,7 +118,7 @@ public actor AttachmentQueue { attachmentsDirectory: String, watchedAttachments: AsyncThrowingStream<[WatchedAttachmentItem], Error>, localStorage: LocalStorageAdapter = FileManagerStorageAdapter(), - attachmentsQueueTableName: String = DEFAULT_TABLE_NAME, + attachmentsQueueTableName: String = defaultTableName, errorHandler: SyncErrorHandler? = nil, syncInterval: TimeInterval = 30.0, archivedCacheLimit: Int64 = 100, @@ -230,71 +227,73 @@ public actor AttachmentQueue { public func processWatchedAttachments(items: [WatchedAttachmentItem]) async throws { // Need to get all the attachments which are tracked in the DB. // We might need to restore an archived attachment. - let currentAttachments = try await attachmentsService.getAttachments() - var attachmentUpdates = [Attachment]() - - for item in items { - let existingQueueItem = currentAttachments.first { $0.id == item.id } - - if existingQueueItem == nil { - if !downloadAttachments { - continue - } - // This item should be added to the queue - // This item is assumed to be coming from an upstream sync - // Locally created new items should be persisted using saveFile before - // this point. - let filename = resolveNewAttachmentFilename( - attachmentId: item.id, - fileExtension: item.fileExtension - ) - - attachmentUpdates.append( - Attachment( - id: item.id, - filename: filename, - state: AttachmentState.queuedDownload.rawValue + try await attachmentsService.withLock { context in + let currentAttachments = try await context.getAttachments() + var attachmentUpdates = [Attachment]() + + for item in items { + let existingQueueItem = currentAttachments.first { $0.id == item.id } + + if existingQueueItem == nil { + if !self.downloadAttachments { + continue + } + // This item should be added to the queue + // This item is assumed to be coming from an upstream sync + // Locally created new items should be persisted using saveFile before + // this point. + let filename = await self.resolveNewAttachmentFilename( + attachmentId: item.id, + fileExtension: item.fileExtension ) - ) - } else if existingQueueItem!.state == AttachmentState.archived.rawValue { - // The attachment is present again. Need to queue it for sync. - // We might be able to optimize this in future - if existingQueueItem!.hasSynced == 1 { - // No remote action required, we can restore the record (avoids deletion) + attachmentUpdates.append( - existingQueueItem!.with(state: AttachmentState.synced.rawValue) + Attachment( + id: item.id, + filename: filename, + state: AttachmentState.queuedDownload.rawValue + ) ) - } else { - // The localURI should be set if the record was meant to be downloaded - // and has been synced. If it's missing and hasSynced is false then - // it must be an upload operation - let newState = existingQueueItem!.localUri == nil ? + } else if existingQueueItem!.state == AttachmentState.archived.rawValue { + // The attachment is present again. Need to queue it for sync. + // We might be able to optimize this in future + if existingQueueItem!.hasSynced == 1 { + // No remote action required, we can restore the record (avoids deletion) + attachmentUpdates.append( + existingQueueItem!.with(state: AttachmentState.synced.rawValue) + ) + } else { + // The localURI should be set if the record was meant to be downloaded + // and has been synced. If it's missing and hasSynced is false then + // it must be an upload operation + let newState = existingQueueItem!.localUri == nil ? AttachmentState.queuedDownload.rawValue : AttachmentState.queuedUpload.rawValue - + + attachmentUpdates.append( + existingQueueItem!.with(state: newState) + ) + } + } + } + + + /** + * Archive any items not specified in the watched items except for items pending delete. + */ + for attachment in currentAttachments { + if attachment.state != AttachmentState.queuedDelete.rawValue && + items.first(where: { $0.id == attachment.id }) == nil { attachmentUpdates.append( - existingQueueItem!.with(state: newState) + attachment.with(state: AttachmentState.archived.rawValue) ) } } - } - - /** - * Archive any items not specified in the watched items except for items pending delete. - */ - for attachment in currentAttachments { - if attachment.state != AttachmentState.queuedDelete.rawValue, - items.first(where: { $0.id == attachment.id }) == nil - { - attachmentUpdates.append( - attachment.with(state: AttachmentState.archived.rawValue) - ) + + if !attachmentUpdates.isEmpty { + try await context.saveAttachments(attachments: attachmentUpdates) } } - - if !attachmentUpdates.isEmpty { - try await attachmentsService.saveAttachments(attachments: attachmentUpdates) - } } /// Saves a new file and schedules it for upload @@ -309,7 +308,7 @@ public actor AttachmentQueue { data: Data, mediaType: String, fileExtension: String?, - updateHook: @escaping (PowerSyncTransaction, Attachment) throws -> Void + updateHook: @escaping (ConnectionContext, Attachment) throws -> Void ) async throws -> Attachment { let id = try await db.get(sql: "SELECT uuid() as id", parameters: [], mapper: { cursor in try cursor.getString(name: "id") @@ -320,23 +319,25 @@ public actor AttachmentQueue { // Write the file to the filesystem let fileSize = try await localStorage.saveFile(filePath: localUri, data: data) + + return try await attachmentsService.withLock { context in + // Start a write transaction. The attachment record and relevant local relationship + // assignment should happen in the same transaction. + return try await self.db.writeTransaction { tx in + let attachment = Attachment( + id: id, + filename: filename, + state: AttachmentState.queuedUpload.rawValue, + localUri: localUri, + mediaType: mediaType, + size: fileSize + ) - // Start a write transaction. The attachment record and relevant local relationship - // assignment should happen in the same transaction. - return try await db.writeTransaction { tx in - let attachment = Attachment( - id: id, - filename: filename, - state: AttachmentState.queuedUpload.rawValue, - localUri: localUri, - mediaType: mediaType, - size: fileSize - ) - - // Allow consumers to set relationships to this attachment id - try updateHook(tx, attachment) - - return try self.attachmentsService.upsertAttachment(attachment, context: tx) + // Allow consumers to set relationships to this attachment id + try updateHook(tx, attachment) + + return try context.upsertAttachment(attachment, context: tx) + } } } @@ -349,24 +350,34 @@ public actor AttachmentQueue { attachmentId: String, updateHook: @escaping (ConnectionContext, Attachment) throws -> Void ) async throws -> Attachment { - guard let attachment = try await attachmentsService.getAttachment(id: attachmentId) else { - throw NSError(domain: "AttachmentError", code: 5, userInfo: [NSLocalizedDescriptionKey: "Attachment record with id \(attachmentId) was not found."]) - } - - return try await db.writeTransaction { tx in - try updateHook(tx, attachment) + try await attachmentsService.withLock { context in + guard let attachment = try await context.getAttachment(id: attachmentId) else { + // TODO defined errors + throw NSError( + domain: "AttachmentError", + code: 5, + userInfo: [NSLocalizedDescriptionKey: "Attachment record with id \(attachmentId) was not found."] + ) + } - let updatedAttachment = Attachment( - id: attachment.id, - filename: attachment.filename, - state: AttachmentState.queuedDelete.rawValue, - hasSynced: attachment.hasSynced, - localUri: attachment.localUri, - mediaType: attachment.mediaType, - size: attachment.size - ) + self.logger.debug("Marking attachment as deleted", tag: nil) + let result = try await self.db.writeTransaction { tx in + try updateHook(tx, attachment) + + let updatedAttachment = Attachment( + id: attachment.id, + filename: attachment.filename, + state: AttachmentState.queuedDelete.rawValue, + hasSynced: attachment.hasSynced, + localUri: attachment.localUri, + mediaType: attachment.mediaType, + size: attachment.size + ) - return try self.attachmentsService.upsertAttachment(updatedAttachment, context: tx) + return try context.upsertAttachment(updatedAttachment, context: tx) + } + self.logger.debug("Marked attachment as deleted", tag: nil) + return result } } @@ -379,16 +390,20 @@ public actor AttachmentQueue { /// Removes all archived items public func expireCache() async throws { - var done = false - repeat { - done = try await syncingService.deleteArchivedAttachments() - } while !done + try await attachmentsService.withLock { context in + var done = false + repeat { + done = try await self.syncingService.deleteArchivedAttachments(context) + } while !done + } } /// Clears the attachment queue and deletes all attachment files public func clearQueue() async throws { - try await attachmentsService.clearQueue() - // Remove the attachments directory - try await localStorage.rmDir(path: attachmentsDirectory) + try await attachmentsService.withLock { context in + try await context.clearQueue() + // Remove the attachments directory + try await self.localStorage.rmDir(path: self.attachmentsDirectory) + } } } diff --git a/Sources/PowerSync/attachments/AttachmentService.swift b/Sources/PowerSync/attachments/AttachmentService.swift new file mode 100644 index 0000000..4ddf044 --- /dev/null +++ b/Sources/PowerSync/attachments/AttachmentService.swift @@ -0,0 +1,94 @@ +import Foundation + +/// Service which manages attachment records. +public actor AttachmentService { + private let db: any PowerSyncDatabaseProtocol + private let tableName: String + private let logger: any LoggerProtocol + private let logTag = "AttachmentService" + + private let context: AttachmentContext + + /// Actor isolation does not automatically queue [withLock] async operations + /// These variables are used to ensure FIFO serial queing + private var lockQueue: [CheckedContinuation] = [] + private var isLocked = false + + /// Initializes the attachment service with the specified database, table name, logger, and max archived count. + public init( + db: PowerSyncDatabaseProtocol, + tableName: String, + logger: any LoggerProtocol, + maxArchivedCount: Int64 + ) { + self.db = db + self.tableName = tableName + self.logger = logger + context = AttachmentContext( + db: db, + tableName: tableName, + logger: logger, + maxArchivedCount: maxArchivedCount + ) + } + + /// Watches for changes to the attachments table. + public func watchActiveAttachments() throws -> AsyncThrowingStream<[String], Error> { + logger.info("Watching attachments...", tag: logTag) + + return try db.watch( + sql: """ + SELECT + id + FROM + \(tableName) + WHERE + state = ? + OR state = ? + OR state = ? + ORDER BY + timestamp ASC + """, + parameters: [ + AttachmentState.queuedUpload.rawValue, + AttachmentState.queuedDownload.rawValue, + AttachmentState.queuedDelete.rawValue, + ] + ) { cursor in + try cursor.getString(name: "id") + } + } + + /// Executes a callback with exclusive access to the attachment context. + public func withLock(callback: @Sendable @escaping (AttachmentContext) async throws -> R) async throws -> R { + // If locked, join the queue + if isLocked { + await withCheckedContinuation { continuation in + lockQueue.append(continuation) + } + } + + // Now we have the lock + isLocked = true + + do { + let result = try await callback(context) + // Release lock and notify next in queue + releaseLock() + return result + } catch { + // Release lock and notify next in queue + releaseLock() + throw error + } + } + + private func releaseLock() { + if let next = lockQueue.first { + lockQueue.removeFirst() + next.resume() + } else { + isLocked = false + } + } +} diff --git a/Sources/PowerSync/attachments/AttachmentsTable.swift b/Sources/PowerSync/attachments/AttachmentTable.swift similarity index 87% rename from Sources/PowerSync/attachments/AttachmentsTable.swift rename to Sources/PowerSync/attachments/AttachmentTable.swift index ae5cae9..d70c166 100644 --- a/Sources/PowerSync/attachments/AttachmentsTable.swift +++ b/Sources/PowerSync/attachments/AttachmentTable.swift @@ -1,5 +1,5 @@ /// Creates a PowerSync Schema table for attachment state -public func createAttachmentsTable(name: String) -> Table { +public func createAttachmentTable(name: String) -> Table { return Table( name: name, columns: [ diff --git a/Sources/PowerSync/attachments/README.md b/Sources/PowerSync/attachments/README.md index f186350..d440166 100644 --- a/Sources/PowerSync/attachments/README.md +++ b/Sources/PowerSync/attachments/README.md @@ -40,12 +40,12 @@ let checklists = Table( let schema = Schema( tables: [ checklists, - createAttachmentsTable(name: "attachments") // Includes the table which stores attachment states + createAttachmentTable(name: "attachments") // Includes the table which stores attachment states ] ) ``` -The `createAttachmentsTable` function defines the local-only attachment state storage table. +The `createAttachmentTable` function defines the local-only attachment state storage table. An attachments table definition can be created with the following options: diff --git a/Sources/PowerSync/attachments/SyncingService.swift b/Sources/PowerSync/attachments/SyncingService.swift index 426a5b7..b6af78a 100644 --- a/Sources/PowerSync/attachments/SyncingService.swift +++ b/Sources/PowerSync/attachments/SyncingService.swift @@ -65,7 +65,7 @@ actor SyncingService { } let watchTask = Task { - for try await _ in try attachmentsService.watchActiveAttachments() { + for try await _ in try await attachmentsService.watchActiveAttachments() { syncTriggerSubject.send(()) } } @@ -74,9 +74,11 @@ actor SyncingService { guard !Task.isCancelled else { break } do { - let attachments = try await attachmentsService.getActiveAttachments() - try await handleSync(attachments: attachments) - _ = try await deleteArchivedAttachments() + try await attachmentsService.withLock { context in + let attachments = try await context.getActiveAttachments() + try await self.handleSync(context: context, attachments: attachments) + _ = try await self.deleteArchivedAttachments(context) + } } catch { if error is CancellationError { break } logger.error("Sync error: \(error)", tag: logTag) @@ -128,8 +130,8 @@ actor SyncingService { /// Deletes attachments marked as archived that exist on local storage. /// /// - Returns: `true` if any deletions occurred, `false` otherwise. - func deleteArchivedAttachments() async throws -> Bool { - return try await attachmentsService.deleteArchivedAttachments { pendingDelete in + func deleteArchivedAttachments(_ context: AttachmentContext) async throws -> Bool { + return try await context.deleteArchivedAttachments { pendingDelete in for attachment in pendingDelete { guard let localUri = attachment.localUri else { continue } if try await !self.localStorage.fileExists(filePath: localUri) { continue } @@ -143,7 +145,7 @@ actor SyncingService { /// This includes queued downloads, uploads, and deletions. /// /// - Parameter attachments: The attachments to process. - private func handleSync(attachments: [Attachment]) async throws { + private func handleSync(context: AttachmentContext, attachments: [Attachment]) async throws { var updatedAttachments = [Attachment]() for attachment in attachments { @@ -164,7 +166,7 @@ actor SyncingService { } } - try await attachmentsService.saveAttachments(attachments: updatedAttachments) + try await context.saveAttachments(attachments: updatedAttachments) } /// Uploads an attachment to remote storage. diff --git a/Tests/PowerSyncTests/Kotlin/AttachmentTests.swift b/Tests/PowerSyncTests/Kotlin/AttachmentTests.swift index 1e5bd34..5e374b7 100644 --- a/Tests/PowerSyncTests/Kotlin/AttachmentTests.swift +++ b/Tests/PowerSyncTests/Kotlin/AttachmentTests.swift @@ -14,7 +14,7 @@ final class AttachmentTests: XCTestCase { .text("email"), .text("photo_id") ]), - createAttachmentsTable(name: "attachments") + createAttachmentTable(name: "attachments") ]) database = PowerSyncDatabase( diff --git a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift index d4ba710..4eef1b6 100644 --- a/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift +++ b/Tests/PowerSyncTests/Kotlin/KotlinPowerSyncDatabaseImplTests.swift @@ -12,8 +12,7 @@ final class KotlinPowerSyncDatabaseImplTests: XCTestCase { .text("name"), .text("email"), .text("photo_id") - ]), - createAttachmentsTable(name: "attachments") + ]) ]) database = KotlinPowerSyncDatabaseImpl( From 4787b8cec254b3cf833bf82a8f94a08c18d725c9 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 15 Apr 2025 12:14:26 +0200 Subject: [PATCH 16/36] Cleanup Attachment State enums. Better Errors. --- .../Screens/PhotoPicker.swift | 79 ------------------- 1 file changed, 79 deletions(-) delete mode 100644 Demo/PowerSyncExample/Screens/PhotoPicker.swift diff --git a/Demo/PowerSyncExample/Screens/PhotoPicker.swift b/Demo/PowerSyncExample/Screens/PhotoPicker.swift deleted file mode 100644 index 5bc8db4..0000000 --- a/Demo/PowerSyncExample/Screens/PhotoPicker.swift +++ /dev/null @@ -1,79 +0,0 @@ -//import PhotosUI -//import SwiftUI -// -//struct PhotoPicker: View { -// @State private var selectedImage: UIImage? -// @State private var imageData: Data? -// @State private var showImagePicker = false -// @State private var showCamera = false -// -// var body: some View { -// VStack { -// if let selectedImage = selectedImage { -// Image(uiImage: selectedImage) -// .resizable() -// .scaledToFit() -// .frame(height: 300) -// -// Text("Image data size: \(imageData?.count ?? 0) bytes") -// } -// -// HStack { -// Button("Camera") { -// showCamera = true -// } -// -// Button("Photo Library") { -// showImagePicker = true -// } -// } -// } -// .sheet(isPresented: $showCamera) { -// CameraView(image: $selectedImage, imageData: $imageData) -// } -// } -//} -// -//struct CameraView: UIViewControllerRepresentable { -// @Binding var image: UIImage? -// @Binding var imageData: Data? -// @Environment(\.presentationMode) var presentationMode -// -// func makeUIViewController(context: Context) -> UIImagePickerController { -// let picker = UIImagePickerController() -// picker.delegate = context.coordinator -// picker.sourceType = .camera -// return picker -// } -// -// func updateUIViewController(_: UIImagePickerController, context _: Context) {} -// -// func makeCoordinator() -> Coordinator { -// Coordinator(self) -// } -// -// class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { -// let parent: CameraView -// -// init(_ parent: CameraView) { -// self.parent = parent -// } -// -// func imagePickerController(_: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { -// if let image = info[.originalImage] as? UIImage { -// parent.image = image -// -// // Convert UIImage to Data -// if let jpegData = image.jpegData(compressionQuality: 0.8) { -// parent.imageData = jpegData -// } -// } -// -// parent.presentationMode.wrappedValue.dismiss() -// } -// -// func imagePickerControllerDidCancel(_: UIImagePickerController) { -// parent.presentationMode.wrappedValue.dismiss() -// } -// } -//} From a64555ae82056e743f31f7552c9de9492fd05e11 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 15 Apr 2025 12:14:32 +0200 Subject: [PATCH 17/36] cleanup --- .../project.pbxproj | 4 --- .../Components/TodoListView.swift | 7 +++-- Demo/PowerSyncExample/PowerSync/Schema.swift | 6 ++--- .../PowerSync/SupabaseConnector.swift | 4 +++ .../PowerSync/SystemManager.swift | 8 +++--- .../PowerSync/attachments/Attachment.swift | 21 +++++++++++---- .../attachments/AttachmentContext.swift | 2 +- .../attachments/AttachmentQueue.swift | 27 ++++++++----------- .../attachments/FileManagerLocalStorage.swift | 6 ++--- .../PowerSync/attachments/LocalStorage.swift | 27 ++++++++++--------- Sources/PowerSync/attachments/README.md | 2 +- .../attachments/SyncingService.swift | 18 ++++++------- 12 files changed, 71 insertions(+), 61 deletions(-) diff --git a/Demo/PowerSyncExample.xcodeproj/project.pbxproj b/Demo/PowerSyncExample.xcodeproj/project.pbxproj index aedc940..e814d01 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.pbxproj +++ b/Demo/PowerSyncExample.xcodeproj/project.pbxproj @@ -41,7 +41,6 @@ B6B3698A2C64F4B30033C307 /* Navigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6B369892C64F4B30033C307 /* Navigation.swift */; }; B6FFD5322D06DA8000EEE60F /* PowerSync in Frameworks */ = {isa = PBXBuildFile; productRef = B6FFD5312D06DA8000EEE60F /* PowerSync */; }; BE2F26EC2DA54B2F0080F1AE /* SupabaseRemoteStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2F26EB2DA54B2A0080F1AE /* SupabaseRemoteStorage.swift */; }; - BE2F26EE2DA555E10080F1AE /* PhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2F26ED2DA555DD0080F1AE /* PhotoPicker.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -109,7 +108,6 @@ B6F4213D2BC42F5B0005D0D0 /* sqlite3.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; name = sqlite3.c; path = "../powersync-kotlin/core/build/interop/sqlite/sqlite3.c"; sourceTree = ""; }; B6F421402BC430B60005D0D0 /* sqlite3.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = sqlite3.h; path = "../powersync-kotlin/core/build/interop/sqlite/sqlite3.h"; sourceTree = ""; }; BE2F26EB2DA54B2A0080F1AE /* SupabaseRemoteStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupabaseRemoteStorage.swift; sourceTree = ""; }; - BE2F26ED2DA555DD0080F1AE /* PhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoPicker.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -197,7 +195,6 @@ B65C4D6B2C60D36700176007 /* Screens */ = { isa = PBXGroup; children = ( - BE2F26ED2DA555DD0080F1AE /* PhotoPicker.swift */, 6A9669032B9EE6FA00B05DCF /* SignInScreen.swift */, B65C4D6C2C60D38B00176007 /* HomeScreen.swift */, B65C4D702C60D7D800176007 /* SignUpScreen.swift */, @@ -574,7 +571,6 @@ B66658632C621CA700159A81 /* AddTodoListView.swift in Sources */, B666585D2C620E9E00159A81 /* WifiIcon.swift in Sources */, 6A9669042B9EE6FA00B05DCF /* SignInScreen.swift in Sources */, - BE2F26EE2DA555E10080F1AE /* PhotoPicker.swift in Sources */, 6A7315BB2B98BDD30004CB17 /* SystemManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Demo/PowerSyncExample/Components/TodoListView.swift b/Demo/PowerSyncExample/Components/TodoListView.swift index ccf042f..9273a64 100644 --- a/Demo/PowerSyncExample/Components/TodoListView.swift +++ b/Demo/PowerSyncExample/Components/TodoListView.swift @@ -69,8 +69,11 @@ struct TodoListView: View { } .onDelete { indexSet in Task { - if let toDelete = indexSet.map({ todos[$0] }).first { - await delete(todo: toDelete) + let selectedItems = indexSet.compactMap { index in + todos.indices.contains(index) ? todos[index] : nil + } + for try todo in selectedItems { + await delete(todo: todo) } } } diff --git a/Demo/PowerSyncExample/PowerSync/Schema.swift b/Demo/PowerSyncExample/PowerSync/Schema.swift index 34fddf2..1865de4 100644 --- a/Demo/PowerSyncExample/PowerSync/Schema.swift +++ b/Demo/PowerSyncExample/PowerSync/Schema.swift @@ -10,7 +10,7 @@ let lists = Table( // ID column is automatically included .text("name"), .text("created_at"), - .text("owner_id"), + .text("owner_id") ] ) @@ -26,13 +26,13 @@ let todos = Table( Column.text("created_at"), Column.text("completed_at"), Column.text("created_by"), - Column.text("completed_by"), + Column.text("completed_by") ], indexes: [ Index( name: "list_id", columns: [IndexedColumn.ascending("list_id")] - ), + ) ] ) diff --git a/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift b/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift index 1f5f5f7..38a21b8 100644 --- a/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift +++ b/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift @@ -131,4 +131,8 @@ class SupabaseConnector: PowerSyncBackendConnector { throw error } } + + deinit { + observeAuthStateChangesTask?.cancel() + } } diff --git a/Demo/PowerSyncExample/PowerSync/SystemManager.swift b/Demo/PowerSyncExample/PowerSync/SystemManager.swift index 50edaba..35142a9 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -6,7 +6,7 @@ func getAttachmentsDirectoryPath() throws -> String { for: .documentDirectory, in: .userDomainMask ).first else { - throw PowerSyncError.attachmentError("Could not determine attachments directory path") + throw PowerSyncAttachmentError.invalidPath("Could not determine attachments directory path") } return documentsURL.appendingPathComponent("attachments").path } @@ -112,7 +112,7 @@ class SystemManager { sql: "DELETE FROM \(LISTS_TABLE) WHERE id = ?", parameters: [id] ) - + // Attachments linked to these will be archived and deleted eventually // Attachments should be deleted explicitly if required _ = try transaction.execute( @@ -184,7 +184,7 @@ class SystemManager { if let attachments, let photoId = todo.photoId { try await attachments.deleteFile( attachmentId: photoId - ) { (tx, _) in + ) { tx, _ in try self.deleteTodoInTX( id: todo.id, tx: tx @@ -199,7 +199,7 @@ class SystemManager { } } } - + func deleteTodoInTX(id: String, tx: ConnectionContext) throws { _ = try tx.execute( sql: "DELETE FROM \(TODOS_TABLE) WHERE id = ?", diff --git a/Sources/PowerSync/attachments/Attachment.swift b/Sources/PowerSync/attachments/Attachment.swift index 017cd08..fa7131e 100644 --- a/Sources/PowerSync/attachments/Attachment.swift +++ b/Sources/PowerSync/attachments/Attachment.swift @@ -10,6 +10,17 @@ public enum AttachmentState: Int { case synced /// The attachment is archived case archived + + enum AttachmentStateError: Error { + case invalidState(Int) + } + + static func from(_ rawValue: Int) throws -> AttachmentState { + guard let state = AttachmentState(rawValue: rawValue) else { + throw AttachmentStateError.invalidState(rawValue) + } + return state + } } /// Struct representing an attachment @@ -23,8 +34,8 @@ public struct Attachment { /// Attachment filename, e.g. `[id].jpg` public let filename: String - /// Current attachment state, represented by the raw value of `AttachmentState` - public let state: Int + /// Current attachment state + public let state: AttachmentState /// Local URI pointing to the attachment file public let localUri: String? @@ -46,7 +57,7 @@ public struct Attachment { public init( id: String, filename: String, - state: Int, + state: AttachmentState, timestamp: Int = 0, hasSynced: Int? = 0, localUri: String? = nil, @@ -79,7 +90,7 @@ public struct Attachment { /// - Returns: A new `Attachment` with updated values. func with( filename _: String? = nil, - state: Int? = nil, + state: AttachmentState? = nil, timestamp _: Int = 0, hasSynced: Int? = 0, localUri: String? = nil, @@ -108,7 +119,7 @@ public struct Attachment { return try Attachment( id: cursor.getString(name: "id"), filename: cursor.getString(name: "filename"), - state: cursor.getLong(name: "state"), + state: AttachmentState.from(cursor.getLong(name: "state")), timestamp: cursor.getLong(name: "timestamp"), hasSynced: cursor.getLongOptional(name: "has_synced"), localUri: cursor.getStringOptional(name: "local_uri"), diff --git a/Sources/PowerSync/attachments/AttachmentContext.swift b/Sources/PowerSync/attachments/AttachmentContext.swift index 9a61cca..c04768a 100644 --- a/Sources/PowerSync/attachments/AttachmentContext.swift +++ b/Sources/PowerSync/attachments/AttachmentContext.swift @@ -205,7 +205,7 @@ public class AttachmentContext { updatedRecord.localUri as Any, updatedRecord.mediaType ?? NSNull(), updatedRecord.size ?? NSNull(), - updatedRecord.state, + updatedRecord.state.rawValue, updatedRecord.hasSynced ?? 0 ] ) diff --git a/Sources/PowerSync/attachments/AttachmentQueue.swift b/Sources/PowerSync/attachments/AttachmentQueue.swift index b9261fd..894aa2c 100644 --- a/Sources/PowerSync/attachments/AttachmentQueue.swift +++ b/Sources/PowerSync/attachments/AttachmentQueue.swift @@ -152,7 +152,7 @@ public actor AttachmentQueue { /// Starts the attachment sync process public func startSync() async throws { if closed { - throw NSError(domain: "AttachmentError", code: 4, userInfo: [NSLocalizedDescriptionKey: "Attachment queue has been closed"]) + throw PowerSyncAttachmentError.closed("Cannot start syncing on closed attachment queue") } // Ensure the directory where attachments are downloaded exists @@ -251,24 +251,24 @@ public actor AttachmentQueue { Attachment( id: item.id, filename: filename, - state: AttachmentState.queuedDownload.rawValue + state: AttachmentState.queuedDownload ) ) - } else if existingQueueItem!.state == AttachmentState.archived.rawValue { + } else if existingQueueItem!.state == AttachmentState.archived { // The attachment is present again. Need to queue it for sync. // We might be able to optimize this in future if existingQueueItem!.hasSynced == 1 { // No remote action required, we can restore the record (avoids deletion) attachmentUpdates.append( - existingQueueItem!.with(state: AttachmentState.synced.rawValue) + existingQueueItem!.with(state: AttachmentState.synced) ) } else { // The localURI should be set if the record was meant to be downloaded // and has been synced. If it's missing and hasSynced is false then // it must be an upload operation let newState = existingQueueItem!.localUri == nil ? - AttachmentState.queuedDownload.rawValue : - AttachmentState.queuedUpload.rawValue + AttachmentState.queuedDownload : + AttachmentState.queuedUpload attachmentUpdates.append( existingQueueItem!.with(state: newState) @@ -282,10 +282,10 @@ public actor AttachmentQueue { * Archive any items not specified in the watched items except for items pending delete. */ for attachment in currentAttachments { - if attachment.state != AttachmentState.queuedDelete.rawValue && + if attachment.state != AttachmentState.queuedDelete && items.first(where: { $0.id == attachment.id }) == nil { attachmentUpdates.append( - attachment.with(state: AttachmentState.archived.rawValue) + attachment.with(state: AttachmentState.archived) ) } } @@ -327,7 +327,7 @@ public actor AttachmentQueue { let attachment = Attachment( id: id, filename: filename, - state: AttachmentState.queuedUpload.rawValue, + state: AttachmentState.queuedUpload, localUri: localUri, mediaType: mediaType, size: fileSize @@ -352,12 +352,7 @@ public actor AttachmentQueue { ) async throws -> Attachment { try await attachmentsService.withLock { context in guard let attachment = try await context.getAttachment(id: attachmentId) else { - // TODO defined errors - throw NSError( - domain: "AttachmentError", - code: 5, - userInfo: [NSLocalizedDescriptionKey: "Attachment record with id \(attachmentId) was not found."] - ) + throw PowerSyncAttachmentError.notFound("Attachment record with id \(attachmentId) was not found.") } self.logger.debug("Marking attachment as deleted", tag: nil) @@ -367,7 +362,7 @@ public actor AttachmentQueue { let updatedAttachment = Attachment( id: attachment.id, filename: attachment.filename, - state: AttachmentState.queuedDelete.rawValue, + state: AttachmentState.queuedDelete, hasSynced: attachment.hasSynced, localUri: attachment.localUri, mediaType: attachment.mediaType, diff --git a/Sources/PowerSync/attachments/FileManagerLocalStorage.swift b/Sources/PowerSync/attachments/FileManagerLocalStorage.swift index 25afd6f..cc3915e 100644 --- a/Sources/PowerSync/attachments/FileManagerLocalStorage.swift +++ b/Sources/PowerSync/attachments/FileManagerLocalStorage.swift @@ -29,14 +29,14 @@ public class FileManagerStorageAdapter: LocalStorageAdapter { let url = URL(fileURLWithPath: filePath) if !fileManager.fileExists(atPath: filePath) { - throw PowerSyncError.fileNotFound(filePath) + throw PowerSyncAttachmentError.fileNotFound(filePath) } // Read data from file do { return try Data(contentsOf: url) } catch { - throw PowerSyncError.ioError(error) + throw PowerSyncAttachmentError.ioError(error) } }.value } @@ -74,7 +74,7 @@ public class FileManagerStorageAdapter: LocalStorageAdapter { public func copyFile(sourcePath: String, targetPath: String) async throws { try await Task { if !fileManager.fileExists(atPath: sourcePath) { - throw PowerSyncError.fileNotFound(sourcePath) + throw PowerSyncAttachmentError.fileNotFound(sourcePath) } // Ensure target directory exists diff --git a/Sources/PowerSync/attachments/LocalStorage.swift b/Sources/PowerSync/attachments/LocalStorage.swift index 4136db6..071e522 100644 --- a/Sources/PowerSync/attachments/LocalStorage.swift +++ b/Sources/PowerSync/attachments/LocalStorage.swift @@ -1,9 +1,12 @@ import Foundation /// Error type for PowerSync operations -public enum PowerSyncError: Error { +public enum PowerSyncAttachmentError: Error { /// A general error with an associated message case generalError(String) + + /// Indicates no matching attachment record could be found + case notFound(String) /// Indicates that a file was not found at the given path case fileNotFound(String) @@ -13,9 +16,9 @@ public enum PowerSyncError: Error { /// The given file or directory path was invalid case invalidPath(String) - - /// An error related to attachment handling - case attachmentError(String) + + /// The attachments queue or sub services have been closed + case closed(String) } /// Protocol defining an adapter interface for local file storage @@ -26,7 +29,7 @@ public protocol LocalStorageAdapter { /// - filePath: The full path where the file should be saved. /// - data: The binary data to save. /// - Returns: The byte size of the saved file. - /// - Throws: `PowerSyncError` if saving fails. + /// - Throws: `PowerSyncAttachmentError` if saving fails. func saveFile( filePath: String, data: Data @@ -38,7 +41,7 @@ public protocol LocalStorageAdapter { /// - filePath: The full path to the file. /// - mediaType: An optional media type (MIME type) to help determine how to handle the file. /// - Returns: The contents of the file as `Data`. - /// - Throws: `PowerSyncError` if reading fails or the file doesn't exist. + /// - Throws: `PowerSyncAttachmentError` if reading fails or the file doesn't exist. func readFile( filePath: String, mediaType: String? @@ -47,26 +50,26 @@ public protocol LocalStorageAdapter { /// Deletes a file at the specified path. /// /// - Parameter filePath: The full path to the file to delete. - /// - Throws: `PowerSyncError` if deletion fails or file doesn't exist. + /// - Throws: `PowerSyncAttachmentError` if deletion fails or file doesn't exist. func deleteFile(filePath: String) async throws /// Checks if a file exists at the specified path. /// /// - Parameter filePath: The path to the file. /// - Returns: `true` if the file exists, `false` otherwise. - /// - Throws: `PowerSyncError` if checking fails. + /// - Throws: `PowerSyncAttachmentError` if checking fails. func fileExists(filePath: String) async throws -> Bool /// Creates a directory at the specified path. /// /// - Parameter path: The full path to the directory. - /// - Throws: `PowerSyncError` if creation fails. + /// - Throws: `PowerSyncAttachmentError` if creation fails. func makeDir(path: String) async throws /// Removes a directory at the specified path. /// /// - Parameter path: The full path to the directory. - /// - Throws: `PowerSyncError` if removal fails. + /// - Throws: `PowerSyncAttachmentError` if removal fails. func rmDir(path: String) async throws /// Copies a file from the source path to the target path. @@ -74,7 +77,7 @@ public protocol LocalStorageAdapter { /// - Parameters: /// - sourcePath: The original file path. /// - targetPath: The destination file path. - /// - Throws: `PowerSyncError` if the copy operation fails. + /// - Throws: `PowerSyncAttachmentError` if the copy operation fails. func copyFile( sourcePath: String, targetPath: String @@ -87,7 +90,7 @@ public extension LocalStorageAdapter { /// /// - Parameter filePath: The full path to the file. /// - Returns: The contents of the file as `Data`. - /// - Throws: `PowerSyncError` if reading fails. + /// - Throws: `PowerSyncAttachmentError` if reading fails. func readFile(filePath: String) async throws -> Data { return try await readFile(filePath: filePath, mediaType: nil) } diff --git a/Sources/PowerSync/attachments/README.md b/Sources/PowerSync/attachments/README.md index d440166..348878b 100644 --- a/Sources/PowerSync/attachments/README.md +++ b/Sources/PowerSync/attachments/README.md @@ -94,7 +94,7 @@ func getAttachmentsDirectoryPath() throws -> String { for: .documentDirectory, in: .userDomainMask ).first else { - throw PowerSyncError.attachmentError("Could not determine attachments directory path") + throw PowerSyncAttachmentError.attachmentError("Could not determine attachments directory path") } return documentsURL.appendingPathComponent("attachments").path } diff --git a/Sources/PowerSync/attachments/SyncingService.swift b/Sources/PowerSync/attachments/SyncingService.swift index b6af78a..4eff4c0 100644 --- a/Sources/PowerSync/attachments/SyncingService.swift +++ b/Sources/PowerSync/attachments/SyncingService.swift @@ -149,9 +149,7 @@ actor SyncingService { var updatedAttachments = [Attachment]() for attachment in attachments { - let state = AttachmentState(rawValue: attachment.state) - - switch state { + switch attachment.state { case .queuedDownload: let updated = try await downloadAttachment(attachment: attachment) updatedAttachments.append(updated) @@ -177,18 +175,18 @@ actor SyncingService { logger.info("Uploading attachment \(attachment.filename)", tag: logTag) do { guard let localUri = attachment.localUri else { - throw PowerSyncError.attachmentError("No localUri for attachment \(attachment.id)") + throw PowerSyncAttachmentError.generalError("No localUri for attachment \(attachment.id)") } let fileData = try await localStorage.readFile(filePath: localUri) try await remoteStorage.uploadFile(fileData: fileData, attachment: attachment) - return attachment.with(state: AttachmentState.synced.rawValue, hasSynced: 1) + return attachment.with(state: AttachmentState.synced, hasSynced: 1) } catch { if let errorHandler = errorHandler { let shouldRetry = await errorHandler.onUploadError(attachment: attachment, error: error) if !shouldRetry { - return attachment.with(state: AttachmentState.archived.rawValue) + return attachment.with(state: AttachmentState.archived) } } return attachment @@ -207,7 +205,7 @@ actor SyncingService { _ = try await localStorage.saveFile(filePath: attachmentPath, data: fileData) return attachment.with( - state: AttachmentState.synced.rawValue, + state: AttachmentState.synced, hasSynced: 1, localUri: attachmentPath ) @@ -215,7 +213,7 @@ actor SyncingService { if let errorHandler = errorHandler { let shouldRetry = await errorHandler.onDownloadError(attachment: attachment, error: error) if !shouldRetry { - return attachment.with(state: AttachmentState.archived.rawValue) + return attachment.with(state: AttachmentState.archived) } } return attachment @@ -235,12 +233,12 @@ actor SyncingService { try await localStorage.deleteFile(filePath: localUri) } - return attachment.with(state: AttachmentState.archived.rawValue) + return attachment.with(state: AttachmentState.archived) } catch { if let errorHandler = errorHandler { let shouldRetry = await errorHandler.onDeleteError(attachment: attachment, error: error) if !shouldRetry { - return attachment.with(state: AttachmentState.archived.rawValue) + return attachment.with(state: AttachmentState.archived) } } return attachment From 156b31f1c53cb1974e127285eacb37f75a8fb82a Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 15 Apr 2025 12:43:00 +0200 Subject: [PATCH 18/36] cleanup --- .../PowerSync/SupabaseConnector.swift | 8 ++- .../PowerSync/SystemManager.swift | 66 +++++++++++-------- .../attachments/AttachmentContext.swift | 33 +++++----- 3 files changed, 64 insertions(+), 43 deletions(-) diff --git a/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift b/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift index 38a21b8..9c3ef46 100644 --- a/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift +++ b/Demo/PowerSyncExample/PowerSync/SupabaseConnector.swift @@ -68,8 +68,12 @@ class SupabaseConnector: PowerSyncBackendConnector { return id.uuidString.lowercased() } - func getStorageBucket() -> StorageFileApi { - return client.storage.from(Secrets.supabaseStorageBucket) + func getStorageBucket() -> StorageFileApi? { + guard let bucket = Secrets.supabaseStorageBucket else { + return nil + } + + return client.storage.from(bucket) } override func fetchCredentials() async throws -> PowerSyncCredentials? { diff --git a/Demo/PowerSyncExample/PowerSync/SystemManager.swift b/Demo/PowerSyncExample/PowerSync/SystemManager.swift index 35142a9..e395095 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -16,40 +16,54 @@ class SystemManager { let connector = SupabaseConnector() let schema = AppSchema let db: PowerSyncDatabaseProtocol - let attachments: AttachmentQueue? - + + var attachments: AttachmentQueue? + init() { db = PowerSyncDatabase( schema: schema, dbFilename: "powersync-swift.sqlite" ) - // Try and configure attachments - do { - let attachmentsDir = try getAttachmentsDirectoryPath() - let watchedAttachments = try db.watch( - options: WatchOptions( - sql: "SELECT photo_id FROM \(TODOS_TABLE) WHERE photo_id IS NOT NULL", - parameters: [], - mapper: { cursor in - try WatchedAttachmentItem( - id: cursor.getString(name: "photo_id"), - fileExtension: "jpg" - ) - } + attachments = Self.createAttachments( + db: db, + connector: connector + ) + } + + private static func createAttachments( + db: PowerSyncDatabaseProtocol, + connector: SupabaseConnector + ) -> AttachmentQueue? { + guard let bucket = connector.getStorageBucket() else { + return nil + } + + do { + let attachmentsDir = try getAttachmentsDirectoryPath() + let watchedAttachments = try db.watch( + options: WatchOptions( + sql: "SELECT photo_id FROM \(TODOS_TABLE) WHERE photo_id IS NOT NULL", + parameters: [], + mapper: { cursor in + try WatchedAttachmentItem( + id: cursor.getString(name: "photo_id"), + fileExtension: "jpg" + ) + } + ) ) - ) - attachments = AttachmentQueue( - db: db, - remoteStorage: SupabaseRemoteStorage(storage: connector.getStorageBucket()), - attachmentsDirectory: attachmentsDir, - watchedAttachments: watchedAttachments - ) - } catch { - print("Failed to initialize attachments queue: \(error)") - attachments = nil + return AttachmentQueue( + db: db, + remoteStorage: SupabaseRemoteStorage(storage: bucket), + attachmentsDirectory: attachmentsDir, + watchedAttachments: watchedAttachments + ) + } catch { + print("Failed to initialize attachments queue: \(error)") + return nil + } } - } func connect() async { do { diff --git a/Sources/PowerSync/attachments/AttachmentContext.swift b/Sources/PowerSync/attachments/AttachmentContext.swift index c04768a..bcea1f6 100644 --- a/Sources/PowerSync/attachments/AttachmentContext.swift +++ b/Sources/PowerSync/attachments/AttachmentContext.swift @@ -28,7 +28,10 @@ public class AttachmentContext { /// Deletes the attachment from the attachment queue. public func deleteAttachment(id: String) async throws { - _ = try await db.execute(sql: "DELETE FROM \(table) WHERE id = ?", parameters: [id]) + _ = try await db.execute( + sql: "DELETE FROM \(table) WHERE id = ?", + parameters: [id] + ) } /// Sets the state of the attachment to ignored (archived). @@ -41,9 +44,12 @@ public class AttachmentContext { /// Gets the attachment from the attachment queue using an ID. public func getAttachment(id: String) async throws -> Attachment? { - return try await db.getOptional(sql: "SELECT * FROM \(table) WHERE id = ?", parameters: [id], mapper: { cursor in + return try await db.getOptional( + sql: "SELECT * FROM \(table) WHERE id = ?", + parameters: [id] + ) { cursor in try Attachment.fromCursor(cursor) - }) + } } /// Saves the attachment to the attachment queue. @@ -70,11 +76,10 @@ public class AttachmentContext { public func getAttachmentIds() async throws -> [String] { return try await db.getAll( sql: "SELECT id FROM \(table) WHERE id IS NOT NULL", - parameters: [], - mapper: { cursor in - try cursor.getString(name: "id") - } - ) + parameters: [] + ) { cursor in + try cursor.getString(name: "id") + } } /// Gets all attachments in the attachment queue. @@ -90,11 +95,10 @@ public class AttachmentContext { ORDER BY timestamp ASC """, - parameters: [], - mapper: { cursor in - try Attachment.fromCursor(cursor) - } - ) + parameters: [] + ) { cursor in + try Attachment.fromCursor(cursor) + } } /// Gets all active attachments that require an operation to be performed. @@ -206,11 +210,10 @@ public class AttachmentContext { updatedRecord.mediaType ?? NSNull(), updatedRecord.size ?? NSNull(), updatedRecord.state.rawValue, - updatedRecord.hasSynced ?? 0 + updatedRecord.hasSynced ?? 0, ] ) return attachment } } - From 8c0e08df120aa854eedbdc4f62e6298ae8722a0c Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 15 Apr 2025 14:43:57 +0200 Subject: [PATCH 19/36] fix build --- Tests/PowerSyncTests/{Kotlin => }/AttachmentTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename Tests/PowerSyncTests/{Kotlin => }/AttachmentTests.swift (99%) diff --git a/Tests/PowerSyncTests/Kotlin/AttachmentTests.swift b/Tests/PowerSyncTests/AttachmentTests.swift similarity index 99% rename from Tests/PowerSyncTests/Kotlin/AttachmentTests.swift rename to Tests/PowerSyncTests/AttachmentTests.swift index 5e374b7..36d4387 100644 --- a/Tests/PowerSyncTests/Kotlin/AttachmentTests.swift +++ b/Tests/PowerSyncTests/AttachmentTests.swift @@ -84,7 +84,7 @@ final class AttachmentTests: XCTestCase { let attachmentRecord = try await waitForMatch( iterator: attachmentsWatch, - where: {results in results.first?.state == AttachmentState.synced.rawValue}, + where: {results in results.first?.state == AttachmentState.synced}, timeout: 5 ).first @@ -159,7 +159,7 @@ final class AttachmentTests: XCTestCase { _ = try await waitForMatch( iterator: attachmentsWatch, - where: {results in results.first?.state == AttachmentState.synced.rawValue}, + where: {results in results.first?.state == AttachmentState.synced}, timeout: 5 ).first From 2b4fc91dddff5317d1c59610b35707d2e84aff1c Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 15 Apr 2025 14:56:17 +0200 Subject: [PATCH 20/36] add changelog --- CHANGELOG.md | 68 ++++++++++++---------- Tests/PowerSyncTests/AttachmentTests.swift | 2 +- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c84ed4b..f884466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,13 @@ # Changelog +# 1.0.0-Beta.11 + +- Added attachment sync helpers + # 1.0.0-Beta.10 -* Added the ability to specify a custom logging implementation +- Added the ability to specify a custom logging implementation + ```swift let db = PowerSyncDatabase( schema: Schema( @@ -19,69 +24,70 @@ logger: DefaultLogger(minSeverity: .debug) ) ``` -* added `.close()` method on `PowerSyncDatabaseProtocol` -* Update `powersync-kotlin` dependency to version `1.0.0-BETA29`, which fixes these issues: - * Fix potential race condition between jobs in `connect()` and `disconnect()`. - * Fix race condition causing data received during uploads not to be applied. - * Fixed issue where automatic driver migrations would fail with the error: + +- added `.close()` method on `PowerSyncDatabaseProtocol` +- Update `powersync-kotlin` dependency to version `1.0.0-BETA29`, which fixes these issues: + - Fix potential race condition between jobs in `connect()` and `disconnect()`. + - Fix race condition causing data received during uploads not to be applied. + - Fixed issue where automatic driver migrations would fail with the error: + ``` Sqlite operation failure database is locked attempted to run migration and failed. closing connection ``` ## 1.0.0-Beta.9 -* Update PowerSync SQLite core extension to 0.3.12. -* Added queuing protection and warnings when connecting multiple PowerSync clients to the same database file. -* Improved concurrent SQLite connection support. A single write connection and multiple read connections are used for concurrent read queries. -* Internally improved the linking of SQLite. -* Enabled Full Text Search support. -* Added the ability to update the schema for existing PowerSync clients. -* Fixed bug where local only, insert only and view name overrides were not applied for schema tables. +- Update PowerSync SQLite core extension to 0.3.12. +- Added queuing protection and warnings when connecting multiple PowerSync clients to the same database file. +- Improved concurrent SQLite connection support. A single write connection and multiple read connections are used for concurrent read queries. +- Internally improved the linking of SQLite. +- Enabled Full Text Search support. +- Added the ability to update the schema for existing PowerSync clients. +- Fixed bug where local only, insert only and view name overrides were not applied for schema tables. ## 1.0.0-Beta.8 -* Improved watch query internals. Added the ability to throttle watched queries. -* Added support for sync bucket priorities. +- Improved watch query internals. Added the ability to throttle watched queries. +- Added support for sync bucket priorities. ## 1.0.0-Beta.7 -* Fixed an issue where throwing exceptions in the query `mapper` could cause a runtime crash. -* Internally improved type casting. +- Fixed an issue where throwing exceptions in the query `mapper` could cause a runtime crash. +- Internally improved type casting. ## 1.0.0-Beta.6 -* BREAKING CHANGE: `watch` queries are now throwable and therefore will need to be accompanied by a `try` e.g. +- BREAKING CHANGE: `watch` queries are now throwable and therefore will need to be accompanied by a `try` e.g. ```swift try database.watch() ``` -* BREAKING CHANGE: `transaction` functions are now throwable and therefore will need to be accompanied by a `try` e.g. +- BREAKING CHANGE: `transaction` functions are now throwable and therefore will need to be accompanied by a `try` e.g. ```swift try await database.writeTransaction { transaction in try transaction.execute(...) } ``` -* Allow `execute` errors to be handled -* `userId` is now set to `nil` by default and therefore it is no longer required to be set to `nil` when instantiating `PowerSyncCredentials` and can therefore be left out. -## 1.0.0-Beta.5 +- Allow `execute` errors to be handled +- `userId` is now set to `nil` by default and therefore it is no longer required to be set to `nil` when instantiating `PowerSyncCredentials` and can therefore be left out. -* Implement improvements to errors originating in Kotlin so that they can be handled in Swift -* Improve `__fetchCredentials`to log the error but not cause an app crash on error +## 1.0.0-Beta.5 +- Implement improvements to errors originating in Kotlin so that they can be handled in Swift +- Improve `__fetchCredentials`to log the error but not cause an app crash on error ## 1.0.0-Beta.4 -* Allow cursor to use column name to get value by including the following functions that accept a column name parameter: -`getBoolean`,`getBooleanOptional`,`getString`,`getStringOptional`, `getLong`,`getLongOptional`, `getDouble`,`getDoubleOptional` -* BREAKING CHANGE: This should not affect anyone but made `KotlinPowerSyncCredentials`, `KotlinPowerSyncDatabase` and `KotlinPowerSyncBackendConnector` private as these should never have been public. - +- Allow cursor to use column name to get value by including the following functions that accept a column name parameter: + `getBoolean`,`getBooleanOptional`,`getString`,`getStringOptional`, `getLong`,`getLongOptional`, `getDouble`,`getDoubleOptional` +- BREAKING CHANGE: This should not affect anyone but made `KotlinPowerSyncCredentials`, `KotlinPowerSyncDatabase` and `KotlinPowerSyncBackendConnector` private as these should never have been public. ## 1.0.0-Beta.3 -* BREAKING CHANGE: Update underlying powersync-kotlin package to BETA18.0 which requires transactions to become synchronous as opposed to asynchronous. +- BREAKING CHANGE: Update underlying powersync-kotlin package to BETA18.0 which requires transactions to become synchronous as opposed to asynchronous. ```swift try await database.writeTransaction { transaction in try await transaction.execute( @@ -102,8 +108,8 @@ try await database.writeTransaction { transaction in ## 1.0.0-Beta.2 -* Upgrade PowerSyncSqliteCore to 0.3.8 +- Upgrade PowerSyncSqliteCore to 0.3.8 ## 1.0.0-Beta.1 -* Initial Beta release +- Initial Beta release diff --git a/Tests/PowerSyncTests/AttachmentTests.swift b/Tests/PowerSyncTests/AttachmentTests.swift index 36d4387..fa54928 100644 --- a/Tests/PowerSyncTests/AttachmentTests.swift +++ b/Tests/PowerSyncTests/AttachmentTests.swift @@ -181,12 +181,12 @@ func waitForMatch( where predicate: @escaping (T) -> Bool, timeout: TimeInterval ) async throws -> T { - var localIterator = iterator let timeoutNanoseconds = UInt64(timeout * 1_000_000_000) return try await withThrowingTaskGroup(of: T.self) { group in // Task to wait for a matching value group.addTask { + var localIterator = iterator while let value = try await localIterator.next() { if predicate(value) { return value From 4765bb2ff75b94418c1bffbd39defda041cdae7f Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 15 Apr 2025 15:12:11 +0200 Subject: [PATCH 21/36] cleanup --- .../PowerSync/attachments/Attachment.swift | 10 +++++----- Sources/PowerSync/attachments/README.md | 20 ++++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/Sources/PowerSync/attachments/Attachment.swift b/Sources/PowerSync/attachments/Attachment.swift index fa7131e..76c9c25 100644 --- a/Sources/PowerSync/attachments/Attachment.swift +++ b/Sources/PowerSync/attachments/Attachment.swift @@ -1,14 +1,14 @@ /// Enum representing the state of an attachment public enum AttachmentState: Int { - /// The attachment is queued for download + /// The attachment has been queued for download from the cloud storage case queuedDownload - /// The attachment is queued for upload + /// The attachment has been queued for upload to the cloud storage case queuedUpload - /// The attachment is queued for deletion + /// The attachment has been queued for delete in the cloud storage (and locally) case queuedDelete - /// The attachment is fully synced + /// The attachment has been synced case synced - /// The attachment is archived + /// The attachment has been orphaned, i.e., the associated record has been deleted case archived enum AttachmentStateError: Error { diff --git a/Sources/PowerSync/attachments/README.md b/Sources/PowerSync/attachments/README.md index 348878b..1aae9f1 100644 --- a/Sources/PowerSync/attachments/README.md +++ b/Sources/PowerSync/attachments/README.md @@ -23,6 +23,8 @@ An `AttachmentQueue` is used to manage and sync attachments in your app. The att ### Example +See the [PowerSync Example Demo](../../../Demo/PowerSyncExample) for a basic example of attachment syncing. + In this example, the user captures photos when checklist items are completed as part of an inspection workflow. The schema for the `checklist` table: @@ -66,7 +68,7 @@ The default columns in `AttachmentTable`: | `has_synced` | `INTEGER` | Internal tracker which tracks if the attachment has ever been synced. This is used for caching/archiving purposes. | | `meta_data` | `TEXT` | Any extra meta data for the attachment. JSON is usually a good choice. | -### Steps to Implement +#### Steps to Implement 1. Implement a `RemoteStorageAdapter` which interfaces with a remote storage provider. This will be used for downloading, uploading, and deleting attachments. @@ -179,9 +181,9 @@ let queue = AttachmentQueue( ) ``` -# Implementation Details +## Implementation Details -## Attachment State +### Attachment State The `AttachmentQueue` class manages attachments in your app by tracking their state. @@ -195,13 +197,13 @@ The state of an attachment can be one of the following: | `SYNCED` | The attachment has been synced | | `ARCHIVED` | The attachment has been orphaned, i.e., the associated record has been deleted | -## Syncing Attachments +### Syncing Attachments The `AttachmentQueue` sets a watched query on the `attachments` table for records in the `QUEUED_UPLOAD`, `QUEUED_DELETE`, and `QUEUED_DOWNLOAD` states. An event loop triggers calls to the remote storage for these operations. In addition to watching for changes, the `AttachmentQueue` also triggers a sync periodically. This will retry any failed uploads/downloads, particularly after the app was offline. By default, this is every 30 seconds but can be configured by setting `syncInterval` in the `AttachmentQueue` constructor options or disabled by setting the interval to `0`. -### Watching State +#### Watching State The `watchedAttachments` publisher provided to the `AttachmentQueue` constructor is used to reconcile the local attachment state. Each emission of the publisher should represent the current attachment state. The updated state is constantly compared to the current queue state. Items are queued based on the difference. @@ -212,7 +214,7 @@ The `watchedAttachments` publisher provided to the `AttachmentQueue` constructor - The attachment state will be updated to `SYNCED`. - Local attachments are archived if the watched state no longer includes the item. Archived items are cached and can be restored if the watched state includes them in the future. The number of cached items is defined by the `archivedCacheLimit` parameter in the `AttachmentQueue` constructor. Items are deleted once the cache limit is reached. -### Uploading +#### Uploading The `saveFile` method provides a simple method for creating attachments that should be uploaded to the backend. This method accepts the raw file content and metadata. This function: @@ -228,7 +230,7 @@ The sync process after calling `saveFile` is: - The `AttachmentQueue` picks this up and, upon successful upload to the remote storage, sets the state to `SYNCED`. - If the upload is not successful, the record remains in the `QUEUED_UPLOAD` state, and uploading will be retried when syncing triggers again. Retries can be stopped by providing an `errorHandler`. -### Downloading +#### Downloading Attachments are scheduled for download when the `watchedAttachments` publisher emits a `WatchedAttachmentItem` not present in the queue. @@ -238,7 +240,7 @@ Attachments are scheduled for download when the `watchedAttachments` publisher e - If this is successful, update the `AttachmentRecord` state to `SYNCED`. - If any of these fail, the download is retried in the next sync trigger. -### Deleting Attachments +#### Deleting Attachments Local attachments are archived and deleted (locally) if the `watchedAttachments` publisher no longer references them. Archived attachments are deleted locally after cache invalidation. @@ -248,7 +250,7 @@ In some cases, users might want to explicitly delete an attachment in the backen - Updates the record to the `QUEUED_DELETE` state. - Allows removing assignments to relational data. -### Expire Cache +#### Expire Cache When PowerSync removes a record, as a result of coming back online or conflict resolution, for instance: From 22c96e9d255166d3adf95dc4efa2c38190fb593f Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 15 Apr 2025 16:53:32 +0200 Subject: [PATCH 22/36] improve concurrency and closing of queues --- .../xcshareddata/swiftpm/Package.resolved | 4 +- .../attachments/AttachmentQueue.swift | 143 ++++++++-------- .../attachments/SyncingService.swift | 153 +++++++++++------- .../attachments/WatchedAttachmentItem.swift | 39 +++++ 4 files changed, 201 insertions(+), 138 deletions(-) create mode 100644 Sources/PowerSync/attachments/WatchedAttachmentItem.swift diff --git a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bdb812a..ae67813 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-kotlin.git", "state" : { - "revision" : "443df078f4b9352de137000b993d564d4ab019b7", - "version" : "1.0.0-BETA28.0" + "revision" : "f306c059580b4c4ee2b36eec3c68f4d5326a454c", + "version" : "1.0.0-BETA29.0" } }, { diff --git a/Sources/PowerSync/attachments/AttachmentQueue.swift b/Sources/PowerSync/attachments/AttachmentQueue.swift index 894aa2c..06efc65 100644 --- a/Sources/PowerSync/attachments/AttachmentQueue.swift +++ b/Sources/PowerSync/attachments/AttachmentQueue.swift @@ -1,42 +1,5 @@ import Combine import Foundation -import OSLog - -/// A watched attachment record item. -/// This is usually returned from watching all relevant attachment IDs. -public struct WatchedAttachmentItem { - /// Id for the attachment record - public let id: String - - /// File extension used to determine an internal filename for storage if no `filename` is provided - public let fileExtension: String? - - /// Filename to store the attachment with - public let filename: String? - - /// Metadata for the attachment (optional) - public let metaData: String? - - /// Initializes a new `WatchedAttachmentItem` - /// - Parameters: - /// - id: Attachment record ID - /// - fileExtension: Optional file extension - /// - filename: Optional filename - /// - metaData: Optional metadata - public init( - id: String, - fileExtension: String? = nil, - filename: String? = nil, - metaData: String? = nil - ) { - self.id = id - self.fileExtension = fileExtension - self.filename = filename - self.metaData = metaData - - precondition(fileExtension != nil || filename != nil, "Either fileExtension or filename must be provided.") - } -} /// Class used to implement the attachment queue /// Requires a PowerSyncDatabase, a RemoteStorageAdapter implementation, and a directory name for attachments. @@ -151,9 +114,10 @@ public actor AttachmentQueue { /// Starts the attachment sync process public func startSync() async throws { - if closed { - throw PowerSyncAttachmentError.closed("Cannot start syncing on closed attachment queue") - } + try guardClosed() + + // Stop any active syncing before starting new Tasks + try await stopSyncing() // Ensure the directory where attachments are downloaded exists try await localStorage.makeDir(path: attachmentsDirectory) @@ -165,48 +129,63 @@ public actor AttachmentQueue { } } - await syncingService.startPeriodicSync(period: syncInterval) + try await syncingService.startSync(period: syncInterval) syncStatusTask = Task { do { - // Create a task for watching connectivity changes - let connectivityTask = Task { - var previousConnected = db.currentStatus.connected - - for await status in db.currentStatus.asFlow() { - if !previousConnected && status.connected { - await syncingService.triggerSync() + try await withThrowingTaskGroup(of: Void.self) { group in + // Add connectivity monitoring task + group.addTask { + var previousConnected = self.db.currentStatus.connected + for await status in self.db.currentStatus.asFlow() { + if !previousConnected && status.connected { + try await self.syncingService.triggerSync() + } + previousConnected = status.connected } - previousConnected = status.connected } - } - // Create a task for watching attachment changes - let watchTask = Task { - for try await items in self.watchedAttachments { - try await self.processWatchedAttachments(items: items) + // Add attachment watching task + group.addTask { + for try await items in self.watchedAttachments { + try await self.processWatchedAttachments(items: items) + } } - } - // Wait for both tasks to complete (they shouldn't unless cancelled) - await connectivityTask.value - try await watchTask.value + // Wait for any task to complete (which should only happen on cancellation) + try await group.next() + } } catch { if !(error is CancellationError) { - logger.error("Error in sync job: \(error.localizedDescription)", tag: logTag) + logger.error("Error in attachment sync job: \(error.localizedDescription)", tag: logTag) } } } } + /// Stops active syncing tasks. Syncing can be resumed with ``startSync()`` + public func stopSyncing() async throws { + try guardClosed() + + syncStatusTask?.cancel() + // Wait for the task to actually complete + do { + _ = try await syncStatusTask?.value + } catch { + // Task completed with error (likely cancellation) + // This is okay + } + syncStatusTask = nil + + try await syncingService.stopSync() + } + /// Closes the attachment queue and cancels all sync tasks public func close() async throws { - if closed { - return - } + try guardClosed() - syncStatusTask?.cancel() - await syncingService.close() + try await stopSyncing() + try await syncingService.close() closed = true } @@ -219,7 +198,7 @@ public actor AttachmentQueue { attachmentId: String, fileExtension: String? ) -> String { - return "\(attachmentId).\(fileExtension ?? "")" + return "\(attachmentId).\(fileExtension ?? "attachment")" } /// Processes watched attachment items and updates sync state @@ -230,10 +209,10 @@ public actor AttachmentQueue { try await attachmentsService.withLock { context in let currentAttachments = try await context.getAttachments() var attachmentUpdates = [Attachment]() - + for item in items { let existingQueueItem = currentAttachments.first { $0.id == item.id } - + if existingQueueItem == nil { if !self.downloadAttachments { continue @@ -246,7 +225,7 @@ public actor AttachmentQueue { attachmentId: item.id, fileExtension: item.fileExtension ) - + attachmentUpdates.append( Attachment( id: item.id, @@ -267,29 +246,29 @@ public actor AttachmentQueue { // and has been synced. If it's missing and hasSynced is false then // it must be an upload operation let newState = existingQueueItem!.localUri == nil ? - AttachmentState.queuedDownload : - AttachmentState.queuedUpload - + AttachmentState.queuedDownload : + AttachmentState.queuedUpload + attachmentUpdates.append( existingQueueItem!.with(state: newState) ) } } } - - + /** * Archive any items not specified in the watched items except for items pending delete. */ for attachment in currentAttachments { - if attachment.state != AttachmentState.queuedDelete && - items.first(where: { $0.id == attachment.id }) == nil { + if attachment.state != AttachmentState.queuedDelete, + items.first(where: { $0.id == attachment.id }) == nil + { attachmentUpdates.append( attachment.with(state: AttachmentState.archived) ) } } - + if !attachmentUpdates.isEmpty { try await context.saveAttachments(attachments: attachmentUpdates) } @@ -319,11 +298,11 @@ public actor AttachmentQueue { // Write the file to the filesystem let fileSize = try await localStorage.saveFile(filePath: localUri, data: data) - + return try await attachmentsService.withLock { context in // Start a write transaction. The attachment record and relevant local relationship // assignment should happen in the same transaction. - return try await self.db.writeTransaction { tx in + try await self.db.writeTransaction { tx in let attachment = Attachment( id: id, filename: filename, @@ -385,7 +364,7 @@ public actor AttachmentQueue { /// Removes all archived items public func expireCache() async throws { - try await attachmentsService.withLock { context in + try await attachmentsService.withLock { context in var done = false repeat { done = try await self.syncingService.deleteArchivedAttachments(context) @@ -401,4 +380,10 @@ public actor AttachmentQueue { try await self.localStorage.rmDir(path: self.attachmentsDirectory) } } + + private func guardClosed() throws { + if closed { + throw PowerSyncAttachmentError.closed("Attachment queue is closed") + } + } } diff --git a/Sources/PowerSync/attachments/SyncingService.swift b/Sources/PowerSync/attachments/SyncingService.swift index 4eff4c0..769e990 100644 --- a/Sources/PowerSync/attachments/SyncingService.swift +++ b/Sources/PowerSync/attachments/SyncingService.swift @@ -18,8 +18,9 @@ actor SyncingService { private var periodicSyncTimer: Timer? private var syncTask: Task? let logger: any LoggerProtocol - + let logTag = "AttachmentSync" + var closed: Bool /// Initializes a new instance of `SyncingService`. /// @@ -46,79 +47,41 @@ actor SyncingService { self.errorHandler = errorHandler self.syncThrottle = syncThrottle self.logger = logger - - Task { await self.setupSyncFlow() } - } - - /// Sets up the main attachment syncing pipeline and starts watching for changes. - private func setupSyncFlow() { - syncTask = Task { - let syncTrigger = AsyncStream { continuation in - let cancellable = syncTriggerSubject - .throttle(for: .seconds(syncThrottle), scheduler: DispatchQueue.global(), latest: true) - .sink { _ in continuation.yield(()) } - - continuation.onTermination = { _ in - cancellable.cancel() - } - self.cancellables.insert(cancellable) - } - - let watchTask = Task { - for try await _ in try await attachmentsService.watchActiveAttachments() { - syncTriggerSubject.send(()) - } - } - - for await _ in syncTrigger { - guard !Task.isCancelled else { break } - - do { - try await attachmentsService.withLock { context in - let attachments = try await context.getActiveAttachments() - try await self.handleSync(context: context, attachments: attachments) - _ = try await self.deleteArchivedAttachments(context) - } - } catch { - if error is CancellationError { break } - logger.error("Sync error: \(error)", tag: logTag) - } - } - - watchTask.cancel() - } + closed = false } /// Starts periodic syncing of attachments. /// /// - Parameter period: The time interval in seconds between each sync. - func startPeriodicSync(period: TimeInterval) async { - if let timer = periodicSyncTimer { - timer.invalidate() - periodicSyncTimer = nil - } + public func startSync(period: TimeInterval) async throws { + try guardClosed() - periodicSyncTimer = Timer.scheduledTimer(withTimeInterval: period, repeats: true) { [weak self] _ in + // Close any active sync operations + try await stopSync() + + setupSyncFlow() + + periodicSyncTimer = Timer.scheduledTimer( + withTimeInterval: period, + repeats: true + ) { [weak self] _ in guard let self = self else { return } - Task { await self.triggerSync() } + Task { try? await self.triggerSync() } } - - await triggerSync() } - /// Triggers a sync operation. Can be called manually. - func triggerSync() async { - syncTriggerSubject.send(()) - } + public func stopSync() async throws { + try guardClosed() - /// Cleans up internal resources and cancels any ongoing syncing. - func close() async { if let timer = periodicSyncTimer { timer.invalidate() periodicSyncTimer = nil } syncTask?.cancel() + + // Wait for the task to actually complete + _ = await syncTask?.value syncTask = nil for cancellable in cancellables { @@ -127,6 +90,20 @@ actor SyncingService { cancellables.removeAll() } + /// Cleans up internal resources and cancels any ongoing syncing. + func close() async throws { + try guardClosed() + + try await stopSync() + closed = true + } + + /// Triggers a sync operation. Can be called manually. + func triggerSync() async throws { + try guardClosed() + syncTriggerSubject.send(()) + } + /// Deletes attachments marked as archived that exist on local storage. /// /// - Returns: `true` if any deletions occurred, `false` otherwise. @@ -140,6 +117,68 @@ actor SyncingService { } } + private func guardClosed() throws { + if closed { + throw PowerSyncAttachmentError.closed("Syncing service is closed") + } + } + + private func createSyncTrigger() -> AsyncStream { + AsyncStream { continuation in + let cancellable = syncTriggerSubject + .throttle( + for: .seconds(syncThrottle), + scheduler: DispatchQueue.global(), + latest: true + ) + .sink { _ in continuation.yield(()) } + + continuation.onTermination = { _ in + cancellable.cancel() + } + self.cancellables.insert(cancellable) + } + } + + /// Sets up the main attachment syncing pipeline and starts watching for changes. + private func setupSyncFlow() { + syncTask = Task { + do { + try await withThrowingTaskGroup(of: Void.self) { group in + // Handle sync trigger events + group.addTask { + let syncTrigger = await self.createSyncTrigger() + + for await _ in syncTrigger { + try Task.checkCancellation() + + try await self.attachmentsService.withLock { context in + let attachments = try await context.getActiveAttachments() + try await self.handleSync(context: context, attachments: attachments) + _ = try await self.deleteArchivedAttachments(context) + } + } + } + + // Watch attachment records. Trigger a sync on change + group.addTask { + for try await _ in try await self.attachmentsService.watchActiveAttachments() { + self.syncTriggerSubject.send(()) + try Task.checkCancellation() + } + } + + // Wait for any task to complete + try await group.next() + } + } catch { + if !(error is CancellationError) { + logger.error("Sync error: \(error)", tag: logTag) + } + } + } + } + /// Handles syncing for a given list of attachments. /// /// This includes queued downloads, uploads, and deletions. diff --git a/Sources/PowerSync/attachments/WatchedAttachmentItem.swift b/Sources/PowerSync/attachments/WatchedAttachmentItem.swift new file mode 100644 index 0000000..b4cddc7 --- /dev/null +++ b/Sources/PowerSync/attachments/WatchedAttachmentItem.swift @@ -0,0 +1,39 @@ + +import Combine +import Foundation + +/// A watched attachment record item. +/// This is usually returned from watching all relevant attachment IDs. +public struct WatchedAttachmentItem { + /// Id for the attachment record + public let id: String + + /// File extension used to determine an internal filename for storage if no `filename` is provided + public let fileExtension: String? + + /// Filename to store the attachment with + public let filename: String? + + /// Metadata for the attachment (optional) + public let metaData: String? + + /// Initializes a new `WatchedAttachmentItem` + /// - Parameters: + /// - id: Attachment record ID + /// - fileExtension: Optional file extension + /// - filename: Optional filename + /// - metaData: Optional metadata + public init( + id: String, + fileExtension: String? = nil, + filename: String? = nil, + metaData: String? = nil + ) { + self.id = id + self.fileExtension = fileExtension + self.filename = filename + self.metaData = metaData + + precondition(fileExtension != nil || filename != nil, "Either fileExtension or filename must be provided.") + } +} From 495ce97933da22f1b787dc1bfd00a92f2c88683b Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 15 Apr 2025 16:58:15 +0200 Subject: [PATCH 23/36] cleanup --- Sources/PowerSync/attachments/README.md | 42 +++++++++++-------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/Sources/PowerSync/attachments/README.md b/Sources/PowerSync/attachments/README.md index 1aae9f1..d1a56d2 100644 --- a/Sources/PowerSync/attachments/README.md +++ b/Sources/PowerSync/attachments/README.md @@ -2,8 +2,6 @@ A [PowerSync](https://powersync.com) library to manage attachments in Swift apps. -This package is included in the PowerSync Core module. - ## Alpha Release Attachment helpers are currently in an alpha state, intended strictly for testing. Expect breaking changes and instability as development continues. @@ -25,7 +23,7 @@ An `AttachmentQueue` is used to manage and sync attachments in your app. The att See the [PowerSync Example Demo](../../../Demo/PowerSyncExample) for a basic example of attachment syncing. -In this example, the user captures photos when checklist items are completed as part of an inspection workflow. +In this example below, the user captures photos when checklist items are completed as part of an inspection workflow. The schema for the `checklist` table: @@ -47,26 +45,7 @@ let schema = Schema( ) ``` -The `createAttachmentTable` function defines the local-only attachment state storage table. - -An attachments table definition can be created with the following options: - -| Option | Description | Default | -| ------ | --------------------- | ------------- | -| `name` | The name of the table | `attachments` | - -The default columns in `AttachmentTable`: - -| Column Name | Type | Description | -| ------------ | --------- | ------------------------------------------------------------------------------------------------------------------ | -| `id` | `TEXT` | The ID of the attachment record | -| `filename` | `TEXT` | The filename of the attachment | -| `media_type` | `TEXT` | The media type of the attachment | -| `state` | `INTEGER` | The state of the attachment, one of `AttachmentState` enum values | -| `timestamp` | `INTEGER` | The timestamp of the last update to the attachment record | -| `size` | `INTEGER` | The size of the attachment in bytes | -| `has_synced` | `INTEGER` | Internal tracker which tracks if the attachment has ever been synced. This is used for caching/archiving purposes. | -| `meta_data` | `TEXT` | Any extra meta data for the attachment. JSON is usually a good choice. | +The `createAttachmentTable` function defines the `local-only` attachment state storage table. See the [Implementation Details](#implementation-details) section for more details. #### Steps to Implement @@ -153,7 +132,7 @@ try await queue.saveFile( } ``` -#### Handling Errors +#### (Optional) Handling Errors The attachment queue automatically retries failed sync operations. Retries continue indefinitely until success. A `SyncErrorHandler` can be provided to the `AttachmentQueue` constructor. This handler provides methods invoked on a remote sync exception. The handler can return a Boolean indicating if the attachment sync should be retried or archived. @@ -183,6 +162,21 @@ let queue = AttachmentQueue( ## Implementation Details +### Attachment Table + +The default columns in `AttachmentTable`: + +| Column Name | Type | Description | +| ------------ | --------- | ------------------------------------------------------------------------------------------------------------------ | +| `id` | `TEXT` | The ID of the attachment record | +| `filename` | `TEXT` | The filename of the attachment | +| `media_type` | `TEXT` | The media type of the attachment | +| `state` | `INTEGER` | The state of the attachment, one of `AttachmentState` enum values | +| `timestamp` | `INTEGER` | The timestamp of the last update to the attachment record | +| `size` | `INTEGER` | The size of the attachment in bytes | +| `has_synced` | `INTEGER` | Internal tracker which tracks if the attachment has ever been synced. This is used for caching/archiving purposes. | +| `meta_data` | `TEXT` | Any extra meta data for the attachment. JSON is usually a good choice. | + ### Attachment State The `AttachmentQueue` class manages attachments in your app by tracking their state. From d405ddd23c37639d38d9c08ea164ddf7aefde2a5 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Tue, 15 Apr 2025 18:17:01 +0200 Subject: [PATCH 24/36] Cleanup demo. Verify local storage. --- .../PowerSync/SystemManager.swift | 82 +++++++++++-------- .../PowerSync/attachments/Attachment.swift | 35 ++++---- .../attachments/AttachmentQueue.swift | 40 +++++++-- Sources/PowerSync/attachments/README.md | 6 +- 4 files changed, 105 insertions(+), 58 deletions(-) diff --git a/Demo/PowerSyncExample/PowerSync/SystemManager.swift b/Demo/PowerSyncExample/PowerSync/SystemManager.swift index e395095..9312a59 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -11,36 +11,45 @@ func getAttachmentsDirectoryPath() throws -> String { return documentsURL.appendingPathComponent("attachments").path } +let logTag = "SystemManager" + @Observable class SystemManager { let connector = SupabaseConnector() let schema = AppSchema let db: PowerSyncDatabaseProtocol - + var attachments: AttachmentQueue? - + init() { db = PowerSyncDatabase( schema: schema, dbFilename: "powersync-swift.sqlite" ) - attachments = Self.createAttachments( + attachments = Self.createAttachmentQueue( db: db, connector: connector ) } - - private static func createAttachments( - db: PowerSyncDatabaseProtocol, - connector: SupabaseConnector - ) -> AttachmentQueue? { - guard let bucket = connector.getStorageBucket() else { - return nil - } - do { - let attachmentsDir = try getAttachmentsDirectoryPath() - let watchedAttachments = try db.watch( + /// Creates an AttachmentQueue if a Supabase Storage bucket has been specified in the config + private static func createAttachmentQueue( + db: PowerSyncDatabaseProtocol, + connector: SupabaseConnector + ) -> AttachmentQueue? { + guard let bucket = connector.getStorageBucket() else { + db.logger.info("No Supabase Storage bucket specified. Skipping attachment queue setup.", tag: logTag) + return nil + } + + do { + let attachmentsDir = try getAttachmentsDirectoryPath() + + return AttachmentQueue( + db: db, + remoteStorage: SupabaseRemoteStorage(storage: bucket), + attachmentsDirectory: attachmentsDir, + watchAttachments: { try db.watch( options: WatchOptions( sql: "SELECT photo_id FROM \(TODOS_TABLE) WHERE photo_id IS NOT NULL", parameters: [], @@ -51,24 +60,16 @@ class SystemManager { ) } ) - ) - - return AttachmentQueue( - db: db, - remoteStorage: SupabaseRemoteStorage(storage: bucket), - attachmentsDirectory: attachmentsDir, - watchedAttachments: watchedAttachments - ) - } catch { - print("Failed to initialize attachments queue: \(error)") - return nil - } + ) } + ) + } catch { + db.logger.error("Failed to initialize attachments queue: \(error)", tag: logTag) + return nil } + } func connect() async { do { - // Only for testing purposes - try await attachments?.clearQueue() try await db.connect(connector: connector) try await attachments?.startSync() } catch { @@ -87,8 +88,8 @@ class SystemManager { func signOut() async throws { try await db.disconnectAndClear() try await connector.client.auth.signOut() + try await attachments?.stopSyncing() try await attachments?.clearQueue() - try await attachments?.close() } func watchLists(_ callback: @escaping (_ lists: [ListContent]) -> Void) async { @@ -121,19 +122,34 @@ class SystemManager { } func deleteList(id: String) async throws { - _ = try await db.writeTransaction(callback: { transaction in + let attachmentIds = try await db.writeTransaction(callback: { transaction in + let attachmentIDs = try transaction.getAll( + sql: "SELECT photo_id FROM \(TODOS_TABLE) WHERE list_id = ? AND photo_id IS NOT NULL", + parameters: [id] + ) { cursor in + cursor.getString(index: 0)! // :( + } as? [String] // :( + _ = try transaction.execute( sql: "DELETE FROM \(LISTS_TABLE) WHERE id = ?", parameters: [id] ) - // Attachments linked to these will be archived and deleted eventually - // Attachments should be deleted explicitly if required _ = try transaction.execute( sql: "DELETE FROM \(TODOS_TABLE) WHERE list_id = ?", parameters: [id] ) + + return attachmentIDs ?? [] // :( }) + + if let attachments { + for id in attachmentIds { + try await attachments.deleteFile( + attachmentId: id + ) { _, _ in } + } + } } func watchTodos(_ listId: String, _ callback: @escaping (_ todos: [Todo]) -> Void) async { @@ -214,7 +230,7 @@ class SystemManager { } } - func deleteTodoInTX(id: String, tx: ConnectionContext) throws { + private func deleteTodoInTX(id: String, tx: ConnectionContext) throws { _ = try tx.execute( sql: "DELETE FROM \(TODOS_TABLE) WHERE id = ?", parameters: [id] diff --git a/Sources/PowerSync/attachments/Attachment.swift b/Sources/PowerSync/attachments/Attachment.swift index 76c9c25..6076015 100644 --- a/Sources/PowerSync/attachments/Attachment.swift +++ b/Sources/PowerSync/attachments/Attachment.swift @@ -2,19 +2,19 @@ public enum AttachmentState: Int { /// The attachment has been queued for download from the cloud storage case queuedDownload - /// The attachment has been queued for upload to the cloud storage + /// The attachment has been queued for upload to the cloud storage case queuedUpload - /// The attachment has been queued for delete in the cloud storage (and locally) + /// The attachment has been queued for delete in the cloud storage (and locally) case queuedDelete - /// The attachment has been synced + /// The attachment has been synced case synced /// The attachment has been orphaned, i.e., the associated record has been deleted case archived - + enum AttachmentStateError: Error { case invalidState(Int) } - + static func from(_ rawValue: Int) throws -> AttachmentState { guard let state = AttachmentState(rawValue: rawValue) else { throw AttachmentStateError.invalidState(rawValue) @@ -92,21 +92,22 @@ public struct Attachment { filename _: String? = nil, state: AttachmentState? = nil, timestamp _: Int = 0, - hasSynced: Int? = 0, - localUri: String? = nil, - mediaType: String? = nil, - size: Int64? = nil, - metaData: String? = nil + hasSynced: Int?? = 0, + localUri: String?? = .none, + mediaType: String?? = .none, + size: Int64?? = .none, + metaData: String?? = .none ) -> Attachment { return Attachment( id: id, - filename: filename, - state: state ?? self.state, - hasSynced: hasSynced ?? self.hasSynced, - localUri: localUri ?? self.localUri, - mediaType: mediaType ?? self.mediaType, - size: size ?? self.size, - metaData: metaData ?? self.metaData + filename: filename ?? filename, + state: state.map { $0 } ?? self.state, + timestamp: timestamp > 0 ? timestamp : timestamp, + hasSynced: hasSynced.map { $0 } ?? self.hasSynced, + localUri: localUri.map { $0 } ?? self.localUri, + mediaType: mediaType.map { $0 } ?? self.mediaType, + size: size.map { $0 } ?? self.size, + metaData: metaData.map { $0 } ?? self.metaData ) } diff --git a/Sources/PowerSync/attachments/AttachmentQueue.swift b/Sources/PowerSync/attachments/AttachmentQueue.swift index 06efc65..fe14f02 100644 --- a/Sources/PowerSync/attachments/AttachmentQueue.swift +++ b/Sources/PowerSync/attachments/AttachmentQueue.swift @@ -18,8 +18,8 @@ public actor AttachmentQueue { /// Directory name for attachments private let attachmentsDirectory: String - /// Stream of watched attachments - private let watchedAttachments: AsyncThrowingStream<[WatchedAttachmentItem], Error> + /// Closure which creates a Stream of ``WatchedAttachmentItem`` + private let watchAttachments: () throws -> AsyncThrowingStream<[WatchedAttachmentItem], Error> /// Local file system adapter public let localStorage: LocalStorageAdapter @@ -79,7 +79,7 @@ public actor AttachmentQueue { db: PowerSyncDatabaseProtocol, remoteStorage: RemoteStorageAdapter, attachmentsDirectory: String, - watchedAttachments: AsyncThrowingStream<[WatchedAttachmentItem], Error>, + watchAttachments: @escaping () throws -> AsyncThrowingStream<[WatchedAttachmentItem], Error>, localStorage: LocalStorageAdapter = FileManagerStorageAdapter(), attachmentsQueueTableName: String = defaultTableName, errorHandler: SyncErrorHandler? = nil, @@ -93,7 +93,7 @@ public actor AttachmentQueue { self.db = db self.remoteStorage = remoteStorage self.attachmentsDirectory = attachmentsDirectory - self.watchedAttachments = watchedAttachments + self.watchAttachments = watchAttachments self.localStorage = localStorage self.attachmentsQueueTableName = attachmentsQueueTableName self.errorHandler = errorHandler @@ -128,6 +128,11 @@ public actor AttachmentQueue { try await localStorage.makeDir(path: path) } } + + // Verify initial state + try await attachmentsService.withLock {context in + try await self.verifyAttachments(context: context) + } try await syncingService.startSync(period: syncInterval) @@ -147,7 +152,7 @@ public actor AttachmentQueue { // Add attachment watching task group.addTask { - for try await items in self.watchedAttachments { + for try await items in try self.watchAttachments() { try await self.processWatchedAttachments(items: items) } } @@ -380,6 +385,31 @@ public actor AttachmentQueue { try await self.localStorage.rmDir(path: self.attachmentsDirectory) } } + + /// Verifies attachment records are present in the filesystem + private func verifyAttachments(context: AttachmentContext) async throws { + let attachments = try await context.getAttachments() + var updates: [Attachment] = [] + + for attachment in attachments { + guard let localUri = attachment.localUri else { + continue + } + + let exists = try await localStorage.fileExists(filePath: localUri) + if attachment.state == AttachmentState.synced || + attachment.state == AttachmentState.queuedUpload && + !exists { + // The file must have been removed from the local storage + updates.append(attachment.with( + state: .archived, + localUri: .some(nil) // Clears the value + )) + } + } + + try await context.saveAttachments(attachments: updates) + } private func guardClosed() throws { if closed { diff --git a/Sources/PowerSync/attachments/README.md b/Sources/PowerSync/attachments/README.md index d1a56d2..c8b5fbe 100644 --- a/Sources/PowerSync/attachments/README.md +++ b/Sources/PowerSync/attachments/README.md @@ -84,7 +84,7 @@ let queue = AttachmentQueue( db: db, attachmentsDirectory: try getAttachmentsDirectoryPath(), remoteStorage: RemoteStorage(), - watchedAttachments: try db.watch( + watchAttachments: { try db.watch( options: WatchOptions( sql: "SELECT photo_id FROM checklists WHERE photo_id IS NOT NULL", parameters: [], @@ -95,13 +95,13 @@ let queue = AttachmentQueue( ) } ) - ) + ) } ) ``` - The `attachmentsDirectory` specifies where local attachment files should be stored. `FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("attachments")` is a good choice. - The `remoteStorage` is responsible for connecting to the attachments backend. See the `RemoteStorageAdapter` protocol definition. -- `watchedAttachments` is a publisher of `WatchedAttachmentItem`. These items represent the attachments that should be present in the application. +- `watchAttachmens` is closure which generates a publisher of `WatchedAttachmentItem`. These items represent the attachments that should be present in the application. 3. Call `startSync()` to start syncing attachments. From 2bdef94d34cf550724a89251a7f5bae857c69a87 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 16 Apr 2025 10:25:48 +0200 Subject: [PATCH 25/36] Improve demo --- .../xcschemes/PowerSyncExample.xcscheme | 1 + .../Components/TodoListRow.swift | 30 ++++++--- .../Components/TodoListView.swift | 29 ++++++--- Tests/PowerSyncTests/AttachmentTests.swift | 63 +++++++++---------- 4 files changed, 71 insertions(+), 52 deletions(-) diff --git a/Demo/PowerSyncExample.xcodeproj/xcshareddata/xcschemes/PowerSyncExample.xcscheme b/Demo/PowerSyncExample.xcodeproj/xcshareddata/xcschemes/PowerSyncExample.xcscheme index 50e13c1..af00361 100644 --- a/Demo/PowerSyncExample.xcodeproj/xcshareddata/xcschemes/PowerSyncExample.xcscheme +++ b/Demo/PowerSyncExample.xcodeproj/xcshareddata/xcschemes/PowerSyncExample.xcscheme @@ -39,6 +39,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/Demo/PowerSyncExample/Components/TodoListRow.swift b/Demo/PowerSyncExample/Components/TodoListRow.swift index 2ab5124..ab6ce33 100644 --- a/Demo/PowerSyncExample/Components/TodoListRow.swift +++ b/Demo/PowerSyncExample/Components/TodoListRow.swift @@ -2,6 +2,7 @@ import SwiftUI struct TodoListRow: View { let todo: Todo + let isCameraAvailable: Bool let completeTapped: () -> Void let deletePhotoTapped: () -> Void let capturePhotoTapped: () -> Void @@ -13,19 +14,20 @@ struct TodoListRow: View { HStack { Text(todo.description) Group { - if todo.photoUri == nil { - // Nothing to display when photoURI is nil - EmptyView() - } else if let image = image { + if let image = image { Image(uiImage: image) .resizable() .scaledToFit() + } else if todo.photoUri != nil { - // Only show loading indicator if we have a URL string + // Show progress while loading the image ProgressView() .onAppear { loadImage() } + } else if todo.photoId != nil { + // Show progres, wait for a URI to be present + ProgressView() } else { EmptyView() } @@ -34,12 +36,14 @@ struct TodoListRow: View { VStack { if todo.photoId == nil { HStack { - Button { - capturePhotoTapped() - } label: { - Image(systemName: "camera.fill") + if isCameraAvailable { + Button { + capturePhotoTapped() + } label: { + Image(systemName: "camera.fill") + } + .buttonStyle(.plain) } - .buttonStyle(.plain) Button { selectPhotoTapped() } label: { @@ -62,6 +66,11 @@ struct TodoListRow: View { Image(systemName: todo.isComplete ? "checkmark.circle.fill" : "circle") } .buttonStyle(.plain) + }.onChange(of: todo.photoId) { _, newPhotoId in + if newPhotoId == nil { + // Clear the image when photoId becomes nil + image = nil + } } } } @@ -93,6 +102,7 @@ struct TodoListRow: View { completedBy: nil, ), + isCameraAvailable: true, completeTapped: {}, deletePhotoTapped: {}, capturePhotoTapped: {} diff --git a/Demo/PowerSyncExample/Components/TodoListView.swift b/Demo/PowerSyncExample/Components/TodoListView.swift index 9273a64..f33097d 100644 --- a/Demo/PowerSyncExample/Components/TodoListView.swift +++ b/Demo/PowerSyncExample/Components/TodoListView.swift @@ -1,3 +1,4 @@ +import AVFoundation import IdentifiedCollections import SwiftUI import SwiftUINavigation @@ -15,6 +16,7 @@ struct TodoListView: View { @State private var onMediaSelect: ((_: Data) async throws -> Void)? @State private var pickMediaType: UIImagePickerController.SourceType = .camera @State private var showMediaPicker = false + @State private var isCameraAvailable: Bool = false var body: some View { List { @@ -33,6 +35,7 @@ struct TodoListView: View { ForEach(todos) { todo in TodoListRow( todo: todo, + isCameraAvailable: isCameraAvailable, completeTapped: { Task { await toggleCompletion(of: todo) @@ -59,13 +62,12 @@ struct TodoListView: View { registerMediaCallback(todo: todo) pickMediaType = .camera showMediaPicker = true - }, - selectPhotoTapped: { - registerMediaCallback(todo: todo) - pickMediaType = .photoLibrary - showMediaPicker = true } - ) + ) { + registerMediaCallback(todo: todo) + pickMediaType = .photoLibrary + showMediaPicker = true + } } .onDelete { indexSet in Task { @@ -109,6 +111,9 @@ struct TodoListView: View { } } } + .onAppear { + checkCameraAvailability() + } .task { await system.watchTodos(listId) { tds in withAnimation { @@ -138,7 +143,7 @@ struct TodoListView: View { self.error = error } } - + /// Registers a callback which saves a photo for the specified Todo item if media is sucessfully loaded. func registerMediaCallback(todo: Todo) { // Register a callback for successful image capture @@ -164,6 +169,12 @@ struct TodoListView: View { } } } + + private func checkCameraAvailability() { + // https://developer.apple.com/forums/thread/748448 + // On MacOS MetalAPI validation needs to be disabled + isCameraAvailable = UIImagePickerController.isSourceTypeAvailable(.camera) + } } #Preview { @@ -177,7 +188,7 @@ struct TodoListView: View { struct CameraView: UIViewControllerRepresentable { @Binding var onMediaSelect: ((_: Data) async throws -> Void)? @Binding var mediaType: UIImagePickerController.SourceType - + @Environment(\.presentationMode) var presentationMode func makeUIViewController(context: Context) -> UIImagePickerController { @@ -214,6 +225,7 @@ struct CameraView: UIViewControllerRepresentable { } } } + parent.onMediaSelect = nil } } @@ -222,6 +234,7 @@ struct CameraView: UIViewControllerRepresentable { func imagePickerControllerDidCancel(_: UIImagePickerController) { parent.presentationMode.wrappedValue.dismiss() + parent.onMediaSelect = nil } } } diff --git a/Tests/PowerSyncTests/AttachmentTests.swift b/Tests/PowerSyncTests/AttachmentTests.swift index fa54928..8963a45 100644 --- a/Tests/PowerSyncTests/AttachmentTests.swift +++ b/Tests/PowerSyncTests/AttachmentTests.swift @@ -44,26 +44,25 @@ final class AttachmentTests: XCTestCase { * Download a file from remote storage */ func downloadFile(attachment: Attachment) async throws -> Data { - return Data([1,2,3]) + return Data([1, 2, 3]) } /** * Delete a file from remote storage */ func deleteFile(attachment: Attachment) async throws {} - } return MockRemoteStorage() }(), attachmentsDirectory: NSTemporaryDirectory(), - watchedAttachments: try database.watch(options: WatchOptions( + watchAttachments: { try self.database.watch(options: WatchOptions( sql: "SELECT photo_id FROM users WHERE photo_id IS NOT NULL", - mapper: { cursor in WatchedAttachmentItem( - id: try cursor.getString(name: "photo_id"), + mapper: { cursor in try WatchedAttachmentItem( + id: cursor.getString(name: "photo_id"), fileExtension: "jpg" - )} - )) + ) } + )) } ) try await queue.startSync() @@ -79,14 +78,14 @@ final class AttachmentTests: XCTestCase { let attachmentsWatch = try database.watch( options: WatchOptions( sql: "SELECT * FROM attachments", - mapper: {cursor in try Attachment.fromCursor(cursor)} + mapper: { cursor in try Attachment.fromCursor(cursor) } )).makeAsyncIterator() - let attachmentRecord = try await waitForMatch( + let attachmentRecord = try await waitForMatch( iterator: attachmentsWatch, - where: {results in results.first?.state == AttachmentState.synced}, + where: { results in results.first?.state == AttachmentState.synced }, timeout: 5 - ).first + ).first // The file should exist let localData = try await queue.localStorage.readFile(filePath: attachmentRecord!.localUri!) @@ -97,7 +96,6 @@ final class AttachmentTests: XCTestCase { } func testAttachmentUpload() async throws { - class MockRemoteStorage: RemoteStorageAdapter { public var uploadCalled = false @@ -105,38 +103,35 @@ final class AttachmentTests: XCTestCase { fileData: Data, attachment: Attachment ) async throws { - self.uploadCalled = true + uploadCalled = true } /** * Download a file from remote storage */ func downloadFile(attachment: Attachment) async throws -> Data { - return Data([1,2,3]) + return Data([1, 2, 3]) } /** * Delete a file from remote storage */ func deleteFile(attachment: Attachment) async throws {} - } - - let mockedRemote = MockRemoteStorage() let queue = AttachmentQueue( db: database, remoteStorage: mockedRemote, attachmentsDirectory: NSTemporaryDirectory(), - watchedAttachments: try database.watch(options: WatchOptions( + watchAttachments: { try self.database.watch(options: WatchOptions( sql: "SELECT photo_id FROM users WHERE photo_id IS NOT NULL", - mapper: { cursor in WatchedAttachmentItem( - id: try cursor.getString(name: "photo_id"), + mapper: { cursor in try WatchedAttachmentItem( + id: cursor.getString(name: "photo_id"), fileExtension: "jpg" - )} - )) + ) } + )) } ) try await queue.startSync() @@ -144,24 +139,25 @@ final class AttachmentTests: XCTestCase { let attachmentsWatch = try database.watch( options: WatchOptions( sql: "SELECT * FROM attachments", - mapper: {cursor in try Attachment.fromCursor(cursor)} + mapper: { cursor in try Attachment.fromCursor(cursor) } )).makeAsyncIterator() _ = try await queue.saveFile( - data: Data([3,4,5]), + data: Data([3, 4, 5]), mediaType: "image/jpg", - fileExtension: "jpg") {tx, attachment in - _ = try tx.execute( - sql: "INSERT INTO users (id, name, email, photo_id) VALUES (uuid(), 'john', 'j@j.com', ?)", - parameters: [attachment.id] - ) - } + fileExtension: "jpg" + ) { tx, attachment in + _ = try tx.execute( + sql: "INSERT INTO users (id, name, email, photo_id) VALUES (uuid(), 'john', 'j@j.com', ?)", + parameters: [attachment.id] + ) + } - _ = try await waitForMatch( + _ = try await waitForMatch( iterator: attachmentsWatch, - where: {results in results.first?.state == AttachmentState.synced}, + where: { results in results.first?.state == AttachmentState.synced }, timeout: 5 - ).first + ).first // Upload should have been called XCTAssertTrue(mockedRemote.uploadCalled) @@ -171,7 +167,6 @@ final class AttachmentTests: XCTestCase { } } - enum WaitForMatchError: Error { case timeout } From 7d7d9a952c3b01e114f812e20d547b826b56aac3 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 16 Apr 2025 11:01:04 +0200 Subject: [PATCH 26/36] update locks --- .../attachments/AttachmentQueue.swift | 119 ++++++++++-------- .../attachments/AttachmentService.swift | 43 ++----- Sources/PowerSync/attachments/LockActor.swift | 26 ++++ 3 files changed, 99 insertions(+), 89 deletions(-) create mode 100644 Sources/PowerSync/attachments/LockActor.swift diff --git a/Sources/PowerSync/attachments/AttachmentQueue.swift b/Sources/PowerSync/attachments/AttachmentQueue.swift index fe14f02..7a38ac6 100644 --- a/Sources/PowerSync/attachments/AttachmentQueue.swift +++ b/Sources/PowerSync/attachments/AttachmentQueue.swift @@ -3,7 +3,7 @@ import Foundation /// Class used to implement the attachment queue /// Requires a PowerSyncDatabase, a RemoteStorageAdapter implementation, and a directory name for attachments. -public actor AttachmentQueue { +public class AttachmentQueue { /// Default name of the attachments table public static let defaultTableName = "attachments" @@ -67,12 +67,14 @@ public actor AttachmentQueue { logger: self.logger, getLocalUri: { [weak self] filename in guard let self = self else { return filename } - return await self.getLocalUri(filename) + return self.getLocalUri(filename) }, errorHandler: self.errorHandler, syncThrottle: self.syncThrottleDuration ) + private let lock: LockActor + /// Initializes the attachment queue /// - Parameters match the stored properties public init( @@ -103,66 +105,68 @@ public actor AttachmentQueue { self.subdirectories = subdirectories self.downloadAttachments = downloadAttachments self.logger = logger ?? db.logger - - attachmentsService = AttachmentService( + self.attachmentsService = AttachmentService( db: db, tableName: attachmentsQueueTableName, logger: self.logger, maxArchivedCount: archivedCacheLimit ) + self.lock = LockActor() } /// Starts the attachment sync process public func startSync() async throws { - try guardClosed() + try await lock.withLock { + try guardClosed() - // Stop any active syncing before starting new Tasks - try await stopSyncing() + // Stop any active syncing before starting new Tasks + try await _stopSyncing() - // Ensure the directory where attachments are downloaded exists - try await localStorage.makeDir(path: attachmentsDirectory) + // Ensure the directory where attachments are downloaded exists + try await localStorage.makeDir(path: attachmentsDirectory) - if let subdirectories = subdirectories { - for subdirectory in subdirectories { - let path = URL(fileURLWithPath: attachmentsDirectory).appendingPathComponent(subdirectory).path - try await localStorage.makeDir(path: path) + if let subdirectories = subdirectories { + for subdirectory in subdirectories { + let path = URL(fileURLWithPath: attachmentsDirectory).appendingPathComponent(subdirectory).path + try await localStorage.makeDir(path: path) + } } - } - - // Verify initial state - try await attachmentsService.withLock {context in - try await self.verifyAttachments(context: context) - } - try await syncingService.startSync(period: syncInterval) - - syncStatusTask = Task { - do { - try await withThrowingTaskGroup(of: Void.self) { group in - // Add connectivity monitoring task - group.addTask { - var previousConnected = self.db.currentStatus.connected - for await status in self.db.currentStatus.asFlow() { - if !previousConnected && status.connected { - try await self.syncingService.triggerSync() + // Verify initial state + try await attachmentsService.withLock { context in + try await self.verifyAttachments(context: context) + } + + try await syncingService.startSync(period: syncInterval) + + syncStatusTask = Task { + do { + try await withThrowingTaskGroup(of: Void.self) { group in + // Add connectivity monitoring task + group.addTask { + var previousConnected = self.db.currentStatus.connected + for await status in self.db.currentStatus.asFlow() { + if !previousConnected && status.connected { + try await self.syncingService.triggerSync() + } + previousConnected = status.connected } - previousConnected = status.connected } - } - // Add attachment watching task - group.addTask { - for try await items in try self.watchAttachments() { - try await self.processWatchedAttachments(items: items) + // Add attachment watching task + group.addTask { + for try await items in try self.watchAttachments() { + try await self.processWatchedAttachments(items: items) + } } - } - // Wait for any task to complete (which should only happen on cancellation) - try await group.next() - } - } catch { - if !(error is CancellationError) { - logger.error("Error in attachment sync job: \(error.localizedDescription)", tag: logTag) + // Wait for any task to complete (which should only happen on cancellation) + try await group.next() + } + } catch { + if !(error is CancellationError) { + logger.error("Error in attachment sync job: \(error.localizedDescription)", tag: logTag) + } } } } @@ -170,6 +174,12 @@ public actor AttachmentQueue { /// Stops active syncing tasks. Syncing can be resumed with ``startSync()`` public func stopSyncing() async throws { + try await lock.withLock { + try await _stopSyncing() + } + } + + private func _stopSyncing() async throws { try guardClosed() syncStatusTask?.cancel() @@ -187,11 +197,13 @@ public actor AttachmentQueue { /// Closes the attachment queue and cancels all sync tasks public func close() async throws { - try guardClosed() + try await lock.withLock { + try guardClosed() - try await stopSyncing() - try await syncingService.close() - closed = true + try await _stopSyncing() + try await syncingService.close() + closed = true + } } /// Resolves the filename for a new attachment @@ -226,7 +238,7 @@ public actor AttachmentQueue { // This item is assumed to be coming from an upstream sync // Locally created new items should be persisted using saveFile before // this point. - let filename = await self.resolveNewAttachmentFilename( + let filename = self.resolveNewAttachmentFilename( attachmentId: item.id, fileExtension: item.fileExtension ) @@ -385,21 +397,22 @@ public actor AttachmentQueue { try await self.localStorage.rmDir(path: self.attachmentsDirectory) } } - + /// Verifies attachment records are present in the filesystem private func verifyAttachments(context: AttachmentContext) async throws { let attachments = try await context.getAttachments() var updates: [Attachment] = [] - + for attachment in attachments { guard let localUri = attachment.localUri else { continue } - + let exists = try await localStorage.fileExists(filePath: localUri) if attachment.state == AttachmentState.synced || attachment.state == AttachmentState.queuedUpload && - !exists { + !exists + { // The file must have been removed from the local storage updates.append(attachment.with( state: .archived, @@ -407,7 +420,7 @@ public actor AttachmentQueue { )) } } - + try await context.saveAttachments(attachments: updates) } diff --git a/Sources/PowerSync/attachments/AttachmentService.swift b/Sources/PowerSync/attachments/AttachmentService.swift index 4ddf044..5942a99 100644 --- a/Sources/PowerSync/attachments/AttachmentService.swift +++ b/Sources/PowerSync/attachments/AttachmentService.swift @@ -1,18 +1,14 @@ import Foundation /// Service which manages attachment records. -public actor AttachmentService { +public class AttachmentService { private let db: any PowerSyncDatabaseProtocol private let tableName: String private let logger: any LoggerProtocol private let logTag = "AttachmentService" private let context: AttachmentContext - - /// Actor isolation does not automatically queue [withLock] async operations - /// These variables are used to ensure FIFO serial queing - private var lockQueue: [CheckedContinuation] = [] - private var isLocked = false + private let lock: LockActor /// Initializes the attachment service with the specified database, table name, logger, and max archived count. public init( @@ -30,6 +26,7 @@ public actor AttachmentService { logger: logger, maxArchivedCount: maxArchivedCount ) + lock = LockActor() } /// Watches for changes to the attachments table. @@ -61,34 +58,8 @@ public actor AttachmentService { /// Executes a callback with exclusive access to the attachment context. public func withLock(callback: @Sendable @escaping (AttachmentContext) async throws -> R) async throws -> R { - // If locked, join the queue - if isLocked { - await withCheckedContinuation { continuation in - lockQueue.append(continuation) - } - } - - // Now we have the lock - isLocked = true - - do { - let result = try await callback(context) - // Release lock and notify next in queue - releaseLock() - return result - } catch { - // Release lock and notify next in queue - releaseLock() - throw error - } - } - - private func releaseLock() { - if let next = lockQueue.first { - lockQueue.removeFirst() - next.resume() - } else { - isLocked = false - } - } + try await lock.withLock { + try await callback(context) + } + } } diff --git a/Sources/PowerSync/attachments/LockActor.swift b/Sources/PowerSync/attachments/LockActor.swift new file mode 100644 index 0000000..d0c6527 --- /dev/null +++ b/Sources/PowerSync/attachments/LockActor.swift @@ -0,0 +1,26 @@ + +internal actor LockActor { + private var isLocked = false + private var queue: [CheckedContinuation] = [] + + func withLock(_ execute: @Sendable () async throws -> T) async throws -> T { + if isLocked { + await withCheckedContinuation { continuation in + queue.append(continuation) + } + } + + isLocked = true + defer { unlockNext() } + return try await execute() + } + + private func unlockNext() { + if let next = queue.first { + queue.removeFirst() + next.resume(returning: ()) + } else { + isLocked = false + } + } +} From 88dbb16385b7e82dfd4973608f5069ec122dcafd Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 16 Apr 2025 11:04:29 +0200 Subject: [PATCH 27/36] update readme --- Sources/PowerSync/attachments/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/PowerSync/attachments/README.md b/Sources/PowerSync/attachments/README.md index c8b5fbe..1327e02 100644 --- a/Sources/PowerSync/attachments/README.md +++ b/Sources/PowerSync/attachments/README.md @@ -45,7 +45,7 @@ let schema = Schema( ) ``` -The `createAttachmentTable` function defines the `local-only` attachment state storage table. See the [Implementation Details](#implementation-details) section for more details. +The `createAttachmentTable` function defines a `local-only` attachment state storage table. See the [Implementation Details](#implementation-details) section for more details. #### Steps to Implement @@ -101,7 +101,7 @@ let queue = AttachmentQueue( - The `attachmentsDirectory` specifies where local attachment files should be stored. `FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("attachments")` is a good choice. - The `remoteStorage` is responsible for connecting to the attachments backend. See the `RemoteStorageAdapter` protocol definition. -- `watchAttachmens` is closure which generates a publisher of `WatchedAttachmentItem`. These items represent the attachments that should be present in the application. +- `watchAttachments` is closure which generates a publisher of `WatchedAttachmentItem`. These items represent the attachments that should be present in the application. 3. Call `startSync()` to start syncing attachments. From 75eb7eac8cf961ff2b3a03bbaa3a862e13a64aeb Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 16 Apr 2025 11:57:21 +0200 Subject: [PATCH 28/36] update locks in syncing service --- .../attachments/SyncingService.swift | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/Sources/PowerSync/attachments/SyncingService.swift b/Sources/PowerSync/attachments/SyncingService.swift index 769e990..961ada4 100644 --- a/Sources/PowerSync/attachments/SyncingService.swift +++ b/Sources/PowerSync/attachments/SyncingService.swift @@ -3,10 +3,10 @@ import Foundation /// A service that synchronizes attachments between local and remote storage. /// -/// This actor watches for changes to active attachments and performs queued +/// This watches for changes to active attachments and performs queued /// download, upload, and delete operations. Syncs can be triggered manually, /// periodically, or based on database changes. -actor SyncingService { +public class SyncingService { private let remoteStorage: RemoteStorageAdapter private let localStorage: LocalStorageAdapter private let attachmentsService: AttachmentService @@ -17,6 +17,7 @@ actor SyncingService { private let syncTriggerSubject = PassthroughSubject() private var periodicSyncTimer: Timer? private var syncTask: Task? + private let lock: LockActor let logger: any LoggerProtocol let logTag = "AttachmentSync" @@ -47,32 +48,32 @@ actor SyncingService { self.errorHandler = errorHandler self.syncThrottle = syncThrottle self.logger = logger - closed = false + self.closed = false + self.lock = LockActor() } /// Starts periodic syncing of attachments. /// /// - Parameter period: The time interval in seconds between each sync. public func startSync(period: TimeInterval) async throws { - try guardClosed() - - // Close any active sync operations - try await stopSync() + try await lock.withLock { + try guardClosed() - setupSyncFlow() + // Close any active sync operations + try await _stopSync() - periodicSyncTimer = Timer.scheduledTimer( - withTimeInterval: period, - repeats: true - ) { [weak self] _ in - guard let self = self else { return } - Task { try? await self.triggerSync() } + setupSyncFlow(period: period) } } public func stopSync() async throws { - try guardClosed() + try await lock.withLock { + try guardClosed() + try await _stopSync() + } + } + private func _stopSync() async throws { if let timer = periodicSyncTimer { timer.invalidate() periodicSyncTimer = nil @@ -92,10 +93,12 @@ actor SyncingService { /// Cleans up internal resources and cancels any ongoing syncing. func close() async throws { - try guardClosed() + try await lock.withLock { + try guardClosed() - try await stopSync() - closed = true + try await _stopSync() + closed = true + } } /// Triggers a sync operation. Can be called manually. @@ -141,13 +144,13 @@ actor SyncingService { } /// Sets up the main attachment syncing pipeline and starts watching for changes. - private func setupSyncFlow() { + private func setupSyncFlow(period: TimeInterval) { syncTask = Task { do { try await withThrowingTaskGroup(of: Void.self) { group in // Handle sync trigger events group.addTask { - let syncTrigger = await self.createSyncTrigger() + let syncTrigger = self.createSyncTrigger() for await _ in syncTrigger { try Task.checkCancellation() @@ -162,12 +165,20 @@ actor SyncingService { // Watch attachment records. Trigger a sync on change group.addTask { - for try await _ in try await self.attachmentsService.watchActiveAttachments() { + for try await _ in try self.attachmentsService.watchActiveAttachments() { self.syncTriggerSubject.send(()) try Task.checkCancellation() } } + group.addTask { + let delay = UInt64(period * 1_000_000_000) + while !Task.isCancelled { + try await Task.sleep(nanoseconds: delay) + try await self.triggerSync() + } + } + // Wait for any task to complete try await group.next() } From bdf544437f8bf2dd6c89ae30b41a8da8325f6543 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 16 Apr 2025 12:04:01 +0200 Subject: [PATCH 29/36] update test directory --- Tests/PowerSyncTests/AttachmentTests.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Tests/PowerSyncTests/AttachmentTests.swift b/Tests/PowerSyncTests/AttachmentTests.swift index 8963a45..cecc6f5 100644 --- a/Tests/PowerSyncTests/AttachmentTests.swift +++ b/Tests/PowerSyncTests/AttachmentTests.swift @@ -29,6 +29,10 @@ final class AttachmentTests: XCTestCase { database = nil try await super.tearDown() } + + func getAttachmentDirectory() -> String { + URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("attachments").path + } func testAttachmentDownload() async throws { let queue = AttachmentQueue( @@ -55,7 +59,7 @@ final class AttachmentTests: XCTestCase { return MockRemoteStorage() }(), - attachmentsDirectory: NSTemporaryDirectory(), + attachmentsDirectory: getAttachmentDirectory(), watchAttachments: { try self.database.watch(options: WatchOptions( sql: "SELECT photo_id FROM users WHERE photo_id IS NOT NULL", mapper: { cursor in try WatchedAttachmentItem( @@ -124,7 +128,7 @@ final class AttachmentTests: XCTestCase { let queue = AttachmentQueue( db: database, remoteStorage: mockedRemote, - attachmentsDirectory: NSTemporaryDirectory(), + attachmentsDirectory: getAttachmentDirectory(), watchAttachments: { try self.database.watch(options: WatchOptions( sql: "SELECT photo_id FROM users WHERE photo_id IS NOT NULL", mapper: { cursor in try WatchedAttachmentItem( From e5d3aecd330e4fa69e4276375f7eadf5345dfa95 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 16 Apr 2025 12:23:45 +0200 Subject: [PATCH 30/36] improve cancellations --- .../Kotlin/KotlinPowerSyncDatabaseImpl.swift | 49 +++++++++++-------- .../attachments/SyncingService.swift | 2 +- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index 486b143..20e672d 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -1,13 +1,12 @@ import Foundation import PowerSyncKotlin -internal final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { +final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { let logger: any LoggerProtocol - + private let kotlinDatabase: PowerSyncKotlin.PowerSyncDatabase var currentStatus: SyncStatus { kotlinDatabase.currentStatus } - init( schema: Schema, @@ -189,44 +188,52 @@ internal final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { options: WatchOptions ) throws -> AsyncThrowingStream<[RowType], Error> { AsyncThrowingStream { continuation in - Task { + // Create an outer task to monitor cancellation + let task = Task { do { var mapperError: Error? - // HACK! - // SKIEE doesn't support custom exceptions in Flows - // Exceptions which occur in the Flow itself cause runtime crashes. - // The most probable crash would be the internal EXPLAIN statement. - // This attempts to EXPLAIN the query before passing it to Kotlin - // We could introduce an onChange API in Kotlin which we use to implement watches here. - // This would prevent most issues with exceptions. + + // EXPLAIN statement to prevent crashes in SKIEE _ = try await self.kotlinDatabase.getAll( sql: "EXPLAIN \(options.sql)", parameters: options.parameters, mapper: { _ in "" } ) + + // Watching for changes in the database for try await values in try self.kotlinDatabase.watch( sql: options.sql, parameters: options.parameters, throttleMs: KotlinLong(value: options.throttleMs), - mapper: { cursor in do { - return try options.mapper(cursor) - } catch { - mapperError = error - // The value here does not matter. We will throw the exception later - // This is not ideal, this is only a workaround until we expose fine grained access to Kotlin SDK internals. - return nil as RowType? - } } + mapper: { cursor in + do { + return try options.mapper(cursor) + } catch { + mapperError = error + return nil as RowType? + } + } ) { + // Check if the outer task is cancelled + try Task.checkCancellation() // This checks if the calling task was cancelled + if mapperError != nil { throw mapperError! } + try continuation.yield(safeCast(values, to: [RowType].self)) } + continuation.finish() } catch { continuation.finish(throwing: error) } } + + // Propagate cancellation from the outer task to the inner task + continuation.onTermination = { @Sendable _ in + task.cancel() // This cancels the inner task when the stream is terminated + } } } @@ -237,8 +244,8 @@ internal final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { func readTransaction(callback: @escaping (any PowerSyncTransaction) throws -> R) async throws -> R { return try safeCast(await kotlinDatabase.readTransaction(callback: TransactionCallback(callback: callback)), to: R.self) } - - func close() async throws{ + + func close() async throws { try await kotlinDatabase.close() } } diff --git a/Sources/PowerSync/attachments/SyncingService.swift b/Sources/PowerSync/attachments/SyncingService.swift index 961ada4..2c2fd55 100644 --- a/Sources/PowerSync/attachments/SyncingService.swift +++ b/Sources/PowerSync/attachments/SyncingService.swift @@ -166,8 +166,8 @@ public class SyncingService { // Watch attachment records. Trigger a sync on change group.addTask { for try await _ in try self.attachmentsService.watchActiveAttachments() { - self.syncTriggerSubject.send(()) try Task.checkCancellation() + self.syncTriggerSubject.send(()) } } From ba03b379d4ff6544995fbf441d4ab7531873460c Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 16 Apr 2025 13:29:01 +0200 Subject: [PATCH 31/36] test --- Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index 20e672d..f881b8b 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -226,7 +226,11 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { continuation.finish() } catch { - continuation.finish(throwing: error) + if error is CancellationError { + continuation.finish() + } else { + continuation.finish(throwing: error) + } } } From f801ec1c6ff0206ed6208178e5188556d8c35412 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 16 Apr 2025 14:04:53 +0200 Subject: [PATCH 32/36] Offload Image loading from main thread --- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- .../Components/TodoListRow.swift | 24 ++++++++++++------- .../PowerSync/SystemManager.swift | 3 ++- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ae67813..d2cc323 100644 --- a/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Demo/PowerSyncExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/powersync-ja/powersync-kotlin.git", "state" : { - "revision" : "f306c059580b4c4ee2b36eec3c68f4d5326a454c", - "version" : "1.0.0-BETA29.0" + "revision" : "633a2924f7893f7ebeb064cbcd9c202937673633", + "version" : "1.0.0-BETA30.0" } }, { diff --git a/Demo/PowerSyncExample/Components/TodoListRow.swift b/Demo/PowerSyncExample/Components/TodoListRow.swift index ab6ce33..f3c3be7 100644 --- a/Demo/PowerSyncExample/Components/TodoListRow.swift +++ b/Demo/PowerSyncExample/Components/TodoListRow.swift @@ -23,7 +23,9 @@ struct TodoListRow: View { // Show progress while loading the image ProgressView() .onAppear { - loadImage() + Task { + await loadImage() + } } } else if todo.photoId != nil { // Show progres, wait for a URI to be present @@ -75,15 +77,19 @@ struct TodoListRow: View { } } - private func loadImage() { - guard let urlString = todo.photoUri else { - return - } + private func loadImage() async { + guard let urlString = todo.photoUri else { return } + let url = URL(fileURLWithPath: urlString) - if let imageData = try? Data(contentsOf: URL(fileURLWithPath: urlString)), - let loadedImage = UIImage(data: imageData) - { - image = loadedImage + do { + let data = try Data(contentsOf: url) + if let loadedImage = UIImage(data: data) { + image = loadedImage + } else { + print("Failed to decode image from data.") + } + } catch { + print("Error loading image from disk:", error) } } } diff --git a/Demo/PowerSyncExample/PowerSync/SystemManager.swift b/Demo/PowerSyncExample/PowerSync/SystemManager.swift index 9312a59..1c0e693 100644 --- a/Demo/PowerSyncExample/PowerSync/SystemManager.swift +++ b/Demo/PowerSyncExample/PowerSync/SystemManager.swift @@ -127,7 +127,8 @@ class SystemManager { sql: "SELECT photo_id FROM \(TODOS_TABLE) WHERE list_id = ? AND photo_id IS NOT NULL", parameters: [id] ) { cursor in - cursor.getString(index: 0)! // :( + // FIXME Transactions should allow throwing in the mapper and should use generics correctly + cursor.getString(index: 0) ?? "invalid" // :( } as? [String] // :( _ = try transaction.execute( From e124ce3250294421e7b52f1f89bf09d442270b59 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 16 Apr 2025 15:16:34 +0200 Subject: [PATCH 33/36] improve camera detection. Better support for cancellations. --- .../Components/TodoListView.swift | 6 +++ .../Kotlin/KotlinPowerSyncDatabaseImpl.swift | 10 +++- .../attachments/AttachmentQueue.swift | 15 +++--- .../attachments/AttachmentService.swift | 2 +- Sources/PowerSync/attachments/LockActor.swift | 48 ++++++++++++++----- .../attachments/SyncingService.swift | 2 +- 6 files changed, 58 insertions(+), 25 deletions(-) diff --git a/Demo/PowerSyncExample/Components/TodoListView.swift b/Demo/PowerSyncExample/Components/TodoListView.swift index f33097d..ea7d4f7 100644 --- a/Demo/PowerSyncExample/Components/TodoListView.swift +++ b/Demo/PowerSyncExample/Components/TodoListView.swift @@ -173,7 +173,13 @@ struct TodoListView: View { private func checkCameraAvailability() { // https://developer.apple.com/forums/thread/748448 // On MacOS MetalAPI validation needs to be disabled + +#if targetEnvironment(simulator) + // Camera does not work on the simulator + isCameraAvailable = false +#else isCameraAvailable = UIImagePickerController.isSourceTypeAvailable(.camera) +#endif } } diff --git a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift index f881b8b..874d4ca 100644 --- a/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift +++ b/Sources/PowerSync/Kotlin/KotlinPowerSyncDatabaseImpl.swift @@ -192,7 +192,13 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { let task = Task { do { var mapperError: Error? - + // HACK! + // SKIEE doesn't support custom exceptions in Flows + // Exceptions which occur in the Flow itself cause runtime crashes. + // The most probable crash would be the internal EXPLAIN statement. + // This attempts to EXPLAIN the query before passing it to Kotlin + // We could introduce an onChange API in Kotlin which we use to implement watches here. + // This would prevent most issues with exceptions. // EXPLAIN statement to prevent crashes in SKIEE _ = try await self.kotlinDatabase.getAll( sql: "EXPLAIN \(options.sql)", @@ -210,7 +216,7 @@ final class KotlinPowerSyncDatabaseImpl: PowerSyncDatabaseProtocol { return try options.mapper(cursor) } catch { mapperError = error - return nil as RowType? + return () } } ) { diff --git a/Sources/PowerSync/attachments/AttachmentQueue.swift b/Sources/PowerSync/attachments/AttachmentQueue.swift index 7a38ac6..b56164b 100644 --- a/Sources/PowerSync/attachments/AttachmentQueue.swift +++ b/Sources/PowerSync/attachments/AttachmentQueue.swift @@ -133,7 +133,7 @@ public class AttachmentQueue { } // Verify initial state - try await attachmentsService.withLock { context in + try await attachmentsService.withContext { context in try await self.verifyAttachments(context: context) } @@ -146,6 +146,7 @@ public class AttachmentQueue { group.addTask { var previousConnected = self.db.currentStatus.connected for await status in self.db.currentStatus.asFlow() { + try Task.checkCancellation() if !previousConnected && status.connected { try await self.syncingService.triggerSync() } @@ -223,7 +224,7 @@ public class AttachmentQueue { public func processWatchedAttachments(items: [WatchedAttachmentItem]) async throws { // Need to get all the attachments which are tracked in the DB. // We might need to restore an archived attachment. - try await attachmentsService.withLock { context in + try await attachmentsService.withContext { context in let currentAttachments = try await context.getAttachments() var attachmentUpdates = [Attachment]() @@ -316,7 +317,7 @@ public class AttachmentQueue { // Write the file to the filesystem let fileSize = try await localStorage.saveFile(filePath: localUri, data: data) - return try await attachmentsService.withLock { context in + return try await attachmentsService.withContext { context in // Start a write transaction. The attachment record and relevant local relationship // assignment should happen in the same transaction. try await self.db.writeTransaction { tx in @@ -346,12 +347,11 @@ public class AttachmentQueue { attachmentId: String, updateHook: @escaping (ConnectionContext, Attachment) throws -> Void ) async throws -> Attachment { - try await attachmentsService.withLock { context in + try await attachmentsService.withContext { context in guard let attachment = try await context.getAttachment(id: attachmentId) else { throw PowerSyncAttachmentError.notFound("Attachment record with id \(attachmentId) was not found.") } - self.logger.debug("Marking attachment as deleted", tag: nil) let result = try await self.db.writeTransaction { tx in try updateHook(tx, attachment) @@ -367,7 +367,6 @@ public class AttachmentQueue { return try context.upsertAttachment(updatedAttachment, context: tx) } - self.logger.debug("Marked attachment as deleted", tag: nil) return result } } @@ -381,7 +380,7 @@ public class AttachmentQueue { /// Removes all archived items public func expireCache() async throws { - try await attachmentsService.withLock { context in + try await attachmentsService.withContext { context in var done = false repeat { done = try await self.syncingService.deleteArchivedAttachments(context) @@ -391,7 +390,7 @@ public class AttachmentQueue { /// Clears the attachment queue and deletes all attachment files public func clearQueue() async throws { - try await attachmentsService.withLock { context in + try await attachmentsService.withContext { context in try await context.clearQueue() // Remove the attachments directory try await self.localStorage.rmDir(path: self.attachmentsDirectory) diff --git a/Sources/PowerSync/attachments/AttachmentService.swift b/Sources/PowerSync/attachments/AttachmentService.swift index 5942a99..b5736d4 100644 --- a/Sources/PowerSync/attachments/AttachmentService.swift +++ b/Sources/PowerSync/attachments/AttachmentService.swift @@ -57,7 +57,7 @@ public class AttachmentService { } /// Executes a callback with exclusive access to the attachment context. - public func withLock(callback: @Sendable @escaping (AttachmentContext) async throws -> R) async throws -> R { + public func withContext(callback: @Sendable @escaping (AttachmentContext) async throws -> R) async throws -> R { try await lock.withLock { try await callback(context) } diff --git a/Sources/PowerSync/attachments/LockActor.swift b/Sources/PowerSync/attachments/LockActor.swift index d0c6527..94f41db 100644 --- a/Sources/PowerSync/attachments/LockActor.swift +++ b/Sources/PowerSync/attachments/LockActor.swift @@ -1,24 +1,46 @@ +import Foundation -internal actor LockActor { +actor LockActor { private var isLocked = false - private var queue: [CheckedContinuation] = [] - - func withLock(_ execute: @Sendable () async throws -> T) async throws -> T { - if isLocked { + private var waiters: [(id: UUID, continuation: CheckedContinuation)] = [] + + func withLock(_ operation: @Sendable () async throws -> T) async throws -> T { + try await waitUntilUnlocked() + + isLocked = true + defer { unlockNext() } + + try Task.checkCancellation() // cancellation check after acquiring lock + return try await operation() + } + + private func waitUntilUnlocked() async throws { + if !isLocked { return } + + let id = UUID() + + // Use withTaskCancellationHandler to manage cancellation + await withTaskCancellationHandler { await withCheckedContinuation { continuation in - queue.append(continuation) + waiters.append((id: id, continuation: continuation)) + } + } onCancel: { + // Cancellation logic: remove the waiter when cancelled + Task { + await self.removeWaiter(id: id) } } - - isLocked = true - defer { unlockNext() } - return try await execute() + } + + private func removeWaiter(id: UUID) async { + // Safely remove the waiter from the actor's waiters list + waiters.removeAll { $0.id == id } } private func unlockNext() { - if let next = queue.first { - queue.removeFirst() - next.resume(returning: ()) + if let next = waiters.first { + waiters.removeFirst() + next.continuation.resume() } else { isLocked = false } diff --git a/Sources/PowerSync/attachments/SyncingService.swift b/Sources/PowerSync/attachments/SyncingService.swift index 2c2fd55..e9ca92f 100644 --- a/Sources/PowerSync/attachments/SyncingService.swift +++ b/Sources/PowerSync/attachments/SyncingService.swift @@ -155,7 +155,7 @@ public class SyncingService { for await _ in syncTrigger { try Task.checkCancellation() - try await self.attachmentsService.withLock { context in + try await self.attachmentsService.withContext { context in let attachments = try await context.getActiveAttachments() try await self.handleSync(context: context, attachments: attachments) _ = try await self.deleteArchivedAttachments(context) From 0cad9a4e8f2264a390809602231e93353c19aa27 Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Wed, 16 Apr 2025 15:19:11 +0200 Subject: [PATCH 34/36] updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bd3be5..828c923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ # 1.0.0-Beta.12 - Added attachment sync helpers +- Added support for cancellations in watched queries # 1.0.0-beta.11 From f9a3d10370ab2d15d6f2ca616ec400ae5ac3b8f2 Mon Sep 17 00:00:00 2001 From: benitav Date: Thu, 17 Apr 2025 12:30:07 +0200 Subject: [PATCH 35/36] Attachments readme polish --- Sources/PowerSync/attachments/README.md | 226 ++++++++++++------------ 1 file changed, 117 insertions(+), 109 deletions(-) diff --git a/Sources/PowerSync/attachments/README.md b/Sources/PowerSync/attachments/README.md index 1327e02..9d85fca 100644 --- a/Sources/PowerSync/attachments/README.md +++ b/Sources/PowerSync/attachments/README.md @@ -1,8 +1,8 @@ # PowerSync Attachment Helpers -A [PowerSync](https://powersync.com) library to manage attachments in Swift apps. +A [PowerSync](https://powersync.com) library to manage attachments (such as images or files) in Swift apps. -## Alpha Release +### Alpha Release Attachment helpers are currently in an alpha state, intended strictly for testing. Expect breaking changes and instability as development continues. @@ -14,18 +14,18 @@ An `AttachmentQueue` is used to manage and sync attachments in your app. The att ### Key Assumptions -- Each attachment should be identifiable by a unique ID. -- Attachments are immutable. -- Relational data should contain a foreign key column that references the attachment ID. -- Relational data should reflect the holistic state of attachments at any given time. An existing local attachment will be deleted locally if no relational data references it. +- Each attachment is identified by a unique ID +- Attachments are immutable once created +- Relational data should reference attachments using a foreign key column +- Relational data should reflect the holistic state of attachments at any given time. An existing local attachment will deleted locally if no relational data references it. -### Example +### Example Implementation See the [PowerSync Example Demo](../../../Demo/PowerSyncExample) for a basic example of attachment syncing. -In this example below, the user captures photos when checklist items are completed as part of an inspection workflow. +In the example below, the user captures photos when checklist items are completed as part of an inspection workflow. -The schema for the `checklist` table: +1. First, define your schema including the `checklist` table and the local-only attachments table: ```swift let checklists = Table( @@ -40,34 +40,14 @@ let checklists = Table( let schema = Schema( tables: [ checklists, - createAttachmentTable(name: "attachments") // Includes the table which stores attachment states + // Add the local-only table which stores attachment states + // Learn more about this function below + createAttachmentTable(name: "attachments") ] ) ``` -The `createAttachmentTable` function defines a `local-only` attachment state storage table. See the [Implementation Details](#implementation-details) section for more details. - -#### Steps to Implement - -1. Implement a `RemoteStorageAdapter` which interfaces with a remote storage provider. This will be used for downloading, uploading, and deleting attachments. - -```swift -class RemoteStorage: RemoteStorageAdapter { - func uploadFile(data: Data, attachment: Attachment) async throws { - // TODO: Make a request to the backend - } - - func downloadFile(attachment: Attachment) async throws -> Data { - // TODO: Make a request to the backend - } - - func deleteFile(attachment: Attachment) async throws { - // TODO: Make a request to the backend - } -} -``` - -2. Create an instance of `AttachmentQueue`. This class provides default syncing utilities and implements a default sync strategy. It can be subclassed for custom functionality. +2. Create an `AttachmentQueue` instance. This class provides default syncing utilities and implements a default sync strategy. It can be subclassed for custom functionality: ```swift func getAttachmentsDirectoryPath() throws -> String { @@ -98,18 +78,37 @@ let queue = AttachmentQueue( ) } ) ``` - - The `attachmentsDirectory` specifies where local attachment files should be stored. `FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("attachments")` is a good choice. - The `remoteStorage` is responsible for connecting to the attachments backend. See the `RemoteStorageAdapter` protocol definition. - `watchAttachments` is closure which generates a publisher of `WatchedAttachmentItem`. These items represent the attachments that should be present in the application. -3. Call `startSync()` to start syncing attachments. +3. Implement a `RemoteStorageAdapter` which interfaces with a remote storage provider. This will be used for downloading, uploading, and deleting attachments. + +```swift +class RemoteStorage: RemoteStorageAdapter { + func uploadFile(data: Data, attachment: Attachment) async throws { + // TODO: Make a request to the backend + } + + func downloadFile(attachment: Attachment) async throws -> Data { + // TODO: Make a request to the backend + } + + func deleteFile(attachment: Attachment) async throws { + // TODO: Make a request to the backend + } +} +``` + +4. Start the sync process: ```swift queue.startSync() ``` -4. To create an attachment and add it to the queue, call `saveFile()`. This method saves the file to local storage, creates an attachment record, queues the file for upload, and allows assigning the newly created attachment ID to a checklist item. +5. Create and save attachments using `saveFile()`. This method will + save the file to the local storage, create an attachment record which queues the file for upload + to the remote storage and allows assigning the newly created attachment ID to a checklist item: ```swift try await queue.saveFile( @@ -132,39 +131,19 @@ try await queue.saveFile( } ``` -#### (Optional) Handling Errors - -The attachment queue automatically retries failed sync operations. Retries continue indefinitely until success. A `SyncErrorHandler` can be provided to the `AttachmentQueue` constructor. This handler provides methods invoked on a remote sync exception. The handler can return a Boolean indicating if the attachment sync should be retried or archived. - -```swift -class ErrorHandler: SyncErrorHandler { - func onDownloadError(attachment: Attachment, error: Error) async -> Bool { - // TODO: Return if the attachment sync should be retried - } +## Implementation Details - func onUploadError(attachment: Attachment, error: Error) async -> Bool { - // TODO: Return if the attachment sync should be retried - } +### Attachment Table Structure - func onDeleteError(attachment: Attachment, error: Error) async -> Bool { - // TODO: Return if the attachment sync should be retried - } -} - -// Pass the handler to the queue constructor -let queue = AttachmentQueue( - db: db, - attachmentsDirectory: attachmentsDirectory, - remoteStorage: remoteStorage, - errorHandler: ErrorHandler() -) -``` +The `createAttachmentsTable` function creates a local-only table for tracking attachment states. -## Implementation Details +An attachments table definition can be created with the following options: -### Attachment Table +| Option | Description | Default | +|--------|-----------------------|---------------| +| `name` | The name of the table | `attachments` | -The default columns in `AttachmentTable`: +The default columns are: | Column Name | Type | Description | | ------------ | --------- | ------------------------------------------------------------------------------------------------------------------ | @@ -177,11 +156,9 @@ The default columns in `AttachmentTable`: | `has_synced` | `INTEGER` | Internal tracker which tracks if the attachment has ever been synced. This is used for caching/archiving purposes. | | `meta_data` | `TEXT` | Any extra meta data for the attachment. JSON is usually a good choice. | -### Attachment State - -The `AttachmentQueue` class manages attachments in your app by tracking their state. +### Attachment States -The state of an attachment can be one of the following: +Attachments are managed through the following states: | State | Description | | ----------------- | ------------------------------------------------------------------------------ | @@ -191,64 +168,95 @@ The state of an attachment can be one of the following: | `SYNCED` | The attachment has been synced | | `ARCHIVED` | The attachment has been orphaned, i.e., the associated record has been deleted | -### Syncing Attachments +### Sync Process + -The `AttachmentQueue` sets a watched query on the `attachments` table for records in the `QUEUED_UPLOAD`, `QUEUED_DELETE`, and `QUEUED_DOWNLOAD` states. An event loop triggers calls to the remote storage for these operations. +The `AttachmentQueue` implements a sync process with these components: -In addition to watching for changes, the `AttachmentQueue` also triggers a sync periodically. This will retry any failed uploads/downloads, particularly after the app was offline. By default, this is every 30 seconds but can be configured by setting `syncInterval` in the `AttachmentQueue` constructor options or disabled by setting the interval to `0`. +1. **State Monitoring**: The queue watches the attachments table for records in `QUEUED_UPLOAD`, `QUEUED_DELETE`, and `QUEUED_DOWNLOAD` states. An event loop triggers calls to the remote storage for these operations. -#### Watching State +2. **Periodic Sync**: By default, the queue triggers a sync every 30 seconds to retry failed uploads/downloads, in particular after the app was offline. This interval can be configured by setting `syncInterval` in the `AttachmentQueue` constructor options, or disabled by setting the interval to `0`. -The `watchedAttachments` publisher provided to the `AttachmentQueue` constructor is used to reconcile the local attachment state. Each emission of the publisher should represent the current attachment state. The updated state is constantly compared to the current queue state. Items are queued based on the difference. +3. **Watching State**: The `watchedAttachments` flow in the `AttachmentQueue` constructor is used to maintain consistency between local and remote states: + - New items trigger downloads - see the Download Process below. + - Missing items trigger archiving - see Cache Management below. -- A new watched item not present in the current queue is treated as an upstream attachment creation that needs to be downloaded. - - An attachment record is created using the provided watched item. The filename will be inferred using a default filename resolver if it has not been provided in the watched item. - - The syncing service will attempt to download the attachment from the remote storage. - - The attachment will be saved to the local filesystem. The `localURI` on the attachment record will be updated. - - The attachment state will be updated to `SYNCED`. -- Local attachments are archived if the watched state no longer includes the item. Archived items are cached and can be restored if the watched state includes them in the future. The number of cached items is defined by the `archivedCacheLimit` parameter in the `AttachmentQueue` constructor. Items are deleted once the cache limit is reached. +#### Upload Process -#### Uploading +The `saveFile` method handles attachment creation and upload: -The `saveFile` method provides a simple method for creating attachments that should be uploaded to the backend. This method accepts the raw file content and metadata. This function: +1. The attachment is saved to local storage +2. An `AttachmentRecord` is created with `QUEUED_UPLOAD` state, linked to the local file using `localURI` +3. The attachment must be assigned to relational data in the same transaction, since this data is constantly watched and should always represent the attachment queue state +4. The `RemoteStorage` `uploadFile` function is called +5. On successful upload, the state changes to `SYNCED` +6. If upload fails, the record stays in `QUEUED_UPLOAD` state for retry -- Persists the attachment to the local filesystem. -- Creates an attachment record linked to the local attachment file. -- Queues the attachment for upload. -- Allows assigning the attachment to relational data. +#### Download Process -The sync process after calling `saveFile` is: +Attachments are scheduled for download when the `watchedAttachments` flow emits a new item that is not present locally: -- An `AttachmentRecord` is created or updated with a state of `QUEUED_UPLOAD`. -- The `RemoteStorageAdapter` `uploadFile` function is called with the `Attachment` record. -- The `AttachmentQueue` picks this up and, upon successful upload to the remote storage, sets the state to `SYNCED`. -- If the upload is not successful, the record remains in the `QUEUED_UPLOAD` state, and uploading will be retried when syncing triggers again. Retries can be stopped by providing an `errorHandler`. +1. An `AttachmentRecord` is created with `QUEUED_DOWNLOAD` state +2. The `RemoteStorage` `downloadFile` function is called +3. The received data is saved to local storage +4. On successful download, the state changes to `SYNCED` +5. If download fails, the operation is retried in the next sync cycle -#### Downloading +### Delete Process -Attachments are scheduled for download when the `watchedAttachments` publisher emits a `WatchedAttachmentItem` not present in the queue. +The `deleteFile` method deletes attachments from both local and remote storage: -- An `AttachmentRecord` is created or updated with the `QUEUED_DOWNLOAD` state. -- The `RemoteStorageAdapter` `downloadFile` function is called with the attachment record. -- The received data is persisted to the local filesystem. -- If this is successful, update the `AttachmentRecord` state to `SYNCED`. -- If any of these fail, the download is retried in the next sync trigger. +1. The attachment record moves to `QUEUED_DELETE` state +2. The attachment must be unassigned from relational data in the same transaction, since this data is constantly watched and should always represent the attachment queue state +3. On successful deletion, the record is removed +4. If deletion fails, the operation is retried in the next sync cycle -#### Deleting Attachments +### Cache Management -Local attachments are archived and deleted (locally) if the `watchedAttachments` publisher no longer references them. Archived attachments are deleted locally after cache invalidation. +The `AttachmentQueue` implements a caching system for archived attachments: -In some cases, users might want to explicitly delete an attachment in the backend. The `deleteFile` function provides a mechanism for this. This function: +1. Local attachments are marked as `ARCHIVED` if the `watchedAttachments` flow no longer references them +2. Archived attachments are kept in the cache for potential future restoration +3. The cache size is controlled by the `archivedCacheLimit` parameter in the `AttachmentQueue` constructor +4. By default, the queue keeps the last 100 archived attachment records +5. When the cache limit is reached, the oldest archived attachments are permanently deleted +6. If an archived attachment is referenced again while still in the cache, it can be restored +7. The cache limit can be configured in the `AttachmentQueue` constructor -- Deletes the attachment on the local filesystem. -- Updates the record to the `QUEUED_DELETE` state. -- Allows removing assignments to relational data. +### Error Handling -#### Expire Cache +1. **Automatic Retries**: + - Failed uploads/downloads/deletes are automatically retried + - The sync interval (default 30 seconds) ensures periodic retry attempts + - Retries continue indefinitely until successful -When PowerSync removes a record, as a result of coming back online or conflict resolution, for instance: +2. **Custom Error Handling**: + - A `SyncErrorHandler` can be implemented to customize retry behavior (see example below) + - The handler can decide whether to retry or archive failed operations + - Different handlers can be provided for upload, download, and delete operations -- Any associated `AttachmentRecord` is orphaned. -- On the next sync trigger, the `AttachmentQueue` sets all orphaned records to the `ARCHIVED` state. -- By default, the `AttachmentQueue` only keeps the last `100` attachment records and then expires the rest. -- In some cases, these records (attachment IDs) might be restored. An archived attachment will be restored if it is still in the cache. This can be configured by setting `cacheLimit` in the `AttachmentQueue` constructor options. +Example of a custom `SyncErrorHandler`: + +```swift +class ErrorHandler: SyncErrorHandler { + func onDownloadError(attachment: Attachment, error: Error) async -> Bool { + // TODO: Return if the attachment sync should be retried + } + + func onUploadError(attachment: Attachment, error: Error) async -> Bool { + // TODO: Return if the attachment sync should be retried + } + + func onDeleteError(attachment: Attachment, error: Error) async -> Bool { + // TODO: Return if the attachment sync should be retried + } +} + +// Pass the handler to the queue constructor +let queue = AttachmentQueue( + db: db, + attachmentsDirectory: attachmentsDirectory, + remoteStorage: remoteStorage, + errorHandler: ErrorHandler() +) +``` \ No newline at end of file From 330d4bf6012f674a28780a9b838de06cf3b059fe Mon Sep 17 00:00:00 2001 From: stevensJourney Date: Thu, 17 Apr 2025 12:44:56 +0200 Subject: [PATCH 36/36] cleanup --- Sources/PowerSync/attachments/AttachmentContext.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/PowerSync/attachments/AttachmentContext.swift b/Sources/PowerSync/attachments/AttachmentContext.swift index bcea1f6..c7f01a6 100644 --- a/Sources/PowerSync/attachments/AttachmentContext.swift +++ b/Sources/PowerSync/attachments/AttachmentContext.swift @@ -198,19 +198,20 @@ public class AttachmentContext { try context.execute( sql: """ INSERT OR REPLACE INTO - \(table) (id, timestamp, filename, local_uri, media_type, size, state, has_synced) + \(table) (id, timestamp, filename, local_uri, media_type, size, state, has_synced, meta_data) VALUES - (?, ?, ?, ?, ?, ?, ?, ?) + (?, ?, ?, ?, ?, ?, ?, ?, ?) """, parameters: [ updatedRecord.id, updatedRecord.timestamp, updatedRecord.filename, - updatedRecord.localUri as Any, + updatedRecord.localUri ?? NSNull(), updatedRecord.mediaType ?? NSNull(), updatedRecord.size ?? NSNull(), updatedRecord.state.rawValue, updatedRecord.hasSynced ?? 0, + updatedRecord.metaData ?? NSNull() ] )