A PowerSync library to manage attachments (such as images or files) in Swift apps.
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.
An AttachmentQueue is used to manage and sync attachments in your app. The attachments' state is stored in a local-only attachments table.
- 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.
See the PowerSync Example Demo for a basic example of attachment syncing.
In the example below, the user captures photos when checklist items are completed as part of an inspection workflow.
- First, define your schema including the
checklisttable and the local-only attachments table:
let checklists = Table(
name: "checklists",
columns: [
Column.text("description"),
Column.integer("completed"),
Column.text("photo_id"),
]
)
let schema = Schema(
tables: [
checklists,
// Add the local-only table which stores attachment states
// Learn more about this function below
createAttachmentTable(name: "attachments")
]
)- Create an
AttachmentQueueinstance. This class provides default syncing utilities and implements a default sync strategy.
func getAttachmentsDirectoryPath() throws -> String {
guard let documentsURL = FileManager.default.urls(
for: .documentDirectory,
in: .userDomainMask
).first else {
throw PowerSyncAttachmentError.attachmentError("Could not determine attachments directory path")
}
return documentsURL.appendingPathComponent("attachments").path
}
let queue = AttachmentQueue(
db: db,
attachmentsDirectory: try getAttachmentsDirectoryPath(),
remoteStorage: RemoteStorage(),
watchAttachments: { 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"
)
}
)
) }
)Note: AttachmentQueue is an Actor which implements AttachmentQueueProtocol. The AttachmentQueueProtocol can be subclassed for custom queue functionality if required.
- The
attachmentsDirectoryspecifies where local attachment files should be stored.FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("attachments")is a good choice. - The
remoteStorageis responsible for connecting to the attachments backend. See theRemoteStorageAdapterprotocol definition. watchAttachmentsis closure which generates a publisher ofWatchedAttachmentItem. These items represent the attachments that should be present in the application.
- Implement a
RemoteStorageAdapterwhich interfaces with a remote storage provider. This will be used for downloading, uploading, and deleting attachments.
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
}
}- Start the sync process:
queue.startSync()- 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:
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]
)
}The createAttachmentsTable function creates a local-only table for tracking attachment states.
An attachments table definition can be created with the following options:
| Option | Description | Default |
|---|---|---|
name |
The name of the table | attachments |
The default columns are:
| 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. |
Attachments are managed through the following states:
| 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 |
The AttachmentQueue implements a sync process with these components:
-
State Monitoring: The queue watches the attachments table for records in
QUEUED_UPLOAD,QUEUED_DELETE, andQUEUED_DOWNLOADstates. An event loop triggers calls to the remote storage for these operations. -
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
syncIntervalin theAttachmentQueueconstructor options, or disabled by setting the interval to0. -
Watching State: The
watchedAttachmentsflow in theAttachmentQueueconstructor 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.
The saveFile method handles attachment creation and upload:
- The attachment is saved to local storage
- An
AttachmentRecordis created withQUEUED_UPLOADstate, linked to the local file usinglocalURI - 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
- The
RemoteStorageuploadFilefunction is called - On successful upload, the state changes to
SYNCED - If upload fails, the record stays in
QUEUED_UPLOADstate for retry
Attachments are scheduled for download when the watchedAttachments flow emits a new item that is not present locally:
- An
AttachmentRecordis created withQUEUED_DOWNLOADstate - The
RemoteStoragedownloadFilefunction is called - The received data is saved to local storage
- On successful download, the state changes to
SYNCED - If download fails, the operation is retried in the next sync cycle
The deleteFile method deletes attachments from both local and remote storage:
- The attachment record moves to
QUEUED_DELETEstate - 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
- On successful deletion, the record is removed
- If deletion fails, the operation is retried in the next sync cycle
The AttachmentQueue implements a caching system for archived attachments:
- Local attachments are marked as
ARCHIVEDif thewatchedAttachmentsflow no longer references them - Archived attachments are kept in the cache for potential future restoration
- The cache size is controlled by the
archivedCacheLimitparameter in theAttachmentQueueconstructor - By default, the queue keeps the last 100 archived attachment records
- When the cache limit is reached, the oldest archived attachments are permanently deleted
- If an archived attachment is referenced again while still in the cache, it can be restored
- The cache limit can be configured in the
AttachmentQueueconstructor
-
Automatic Retries:
- Failed uploads/downloads/deletes are automatically retried
- The sync interval (default 30 seconds) ensures periodic retry attempts
- Retries continue indefinitely until successful
-
Custom Error Handling:
- A
SyncErrorHandlercan 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
- A
Example of a custom SyncErrorHandler:
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()
)