14 KiB
| name | description | metadata | ||
|---|---|---|---|---|
| powersync-attachments | PowerSync built-in attachment queue — offline-first file uploads/downloads, storage adapters, AttachmentQueue lifecycle, saveFile, watchAttachments, and error handling |
|
PowerSync Attachments
Load this when the app needs file uploads/downloads (images, documents, media) synced alongside PowerSync data.
Table of Contents
- How It Works
- Package Setup
- Schema Setup
- Storage Adapters
- Initialize the Attachment Queue
- Upload / Delete / Access Files
- Error Handling
PowerSync handles file attachments using a metadata + storage provider pattern: structured metadata syncs through PowerSync while actual files live in a purpose-built storage system (S3, Supabase Storage, Cloudflare R2, etc.). An offline-first queue manages uploads, downloads, and retries automatically in the background.
| Resource | Description |
|---|---|
| Attachments docs | Full reference including migration notes and platform demos |
Deprecated packages:
@powersync/attachments(JS/TS) andpowersync_attachments_helper(Dart/Flutter) are deprecated. Attachment functionality is now built in to the platform SDK packages. Do not install the old packages for new projects.
How It Works
sequenceDiagram
participant DevA as Device A
participant AQueue as Attachment Queue (A)
participant Remote as Remote Storage (S3/Supabase)
participant PS as PowerSync Service
participant BQueue as Attachment Queue (B)
participant DevB as Device B
DevA->>AQueue: 1. saveFile()
Note over AQueue: 2. File saved locally,<br/>state = QUEUED_UPLOAD
AQueue->>Remote: 3. Upload file
Note over Remote: 4. File stored in Bucket/id-123
AQueue->>DevA: 5. updateHook() — data model updated<br/>(photo_id = "id-123"), state = SYNCED
DevA->>PS: 6. PowerSync syncs data model change
PS->>DevB: 7. Data model updated (photo_id = "id-123")
DevB->>BQueue: 8. watchAttachments() detects new ID,<br/>state = QUEUED_DOWNLOAD
BQueue->>Remote: 9. Download file
Remote-->>BQueue: 10. File stored locally
Note over BQueue: 11. State = SYNCED
- App calls
saveFile()— file is written to local storage immediately, a record is inserted into the local attachments table with stateQUEUED_UPLOAD, and theupdateHooklinks the attachment ID to your data model in the same transaction. - The attachment queue uploads the file to remote storage in the background.
- On upload success, the record transitions to
SYNCED. - PowerSync syncs the data model change (e.g.
user.photo_id) to other devices. - Those devices'
watchAttachmentsquery detects the new ID, creates aQUEUED_DOWNLOADrecord, and the queue downloads the file automatically.
Attachment States
| State | Meaning |
|---|---|
QUEUED_UPLOAD |
Saved locally, waiting to upload |
QUEUED_DOWNLOAD |
ID received from sync, file not yet downloaded |
SYNCED |
File exists locally and in remote storage |
QUEUED_DELETE |
Marked for deletion from both local and remote |
ARCHIVED |
No longer referenced in data model; candidate for cleanup |
Package Setup
Web / Node.js — built in, no separate install:
# Already included in @powersync/web and @powersync/node
React Native — built in to @powersync/react-native, but local storage adapter requires a separate package:
# Expo (requires Expo 54+)
npx expo install @powersync/attachments-storage-react-native expo-file-system
# Bare React Native
npm install @powersync/attachments-storage-react-native @dr.pogodin/react-native-fs
Flutter/Dart, Kotlin, Swift, .NET — built in to the respective SDK, no additional package needed.
Schema Setup
Add AttachmentTable as a local-only table alongside your regular tables. It is not synced through PowerSync — it is managed entirely by the attachment queue on each device.
JavaScript / TypeScript:
import { Schema, Table, column, AttachmentTable } from '@powersync/web';
// or from '@powersync/react-native' / '@powersync/node'
export const AppSchema = new Schema({
users: new Table({
name: column.text,
photo_id: column.text, // FK referencing attachment ID
}),
attachments: new AttachmentTable(), // local-only, managed by queue
});
AttachmentTable accepts an optional viewName (default: 'attachments'). Use a custom viewName when migrating from the old @powersync/attachments package to avoid a SQLite name conflict with the legacy table.
Dart:
import 'package:powersync/powersync.dart';
import 'package:powersync_core/attachments/attachments.dart';
final schema = Schema([
Table('users', [Column.text('name'), Column.text('photo_id')]),
AttachmentsQueueTable(),
]);
Kotlin / Swift — use createAttachmentsTable("attachments") / createAttachmentTable(name: "attachments") respectively. See SDK demos for full examples.
.NET:
using PowerSync.Common.Attachments;
using PowerSync.Common.DB.Schema;
var users = new Table("users", new Dictionary<string, ColumnType>
{
["name"] = ColumnType.Text,
["photo_id"] = ColumnType.Text, // FK referencing attachment ID
});
// new Table(typeof(Attachment)) uses the built-in [Table("attachments", LocalOnly = true)] attribute
var schema = new Schema(users, new Table(typeof(Attachment)));
Storage Adapters
The attachment queue requires two adapters:
localStorage— reads/writes files on the local deviceremoteStorage— uploads/downloads files to/from cloud storage
Local Storage Adapters (JS/TS)
// Web (IndexedDB)
import { IndexDBFileSystemStorageAdapter } from '@powersync/web';
const localStorage = new IndexDBFileSystemStorageAdapter('my-app-files');
// Node.js / Electron
import { NodeFileSystemAdapter } from '@powersync/node';
const localStorage = new NodeFileSystemAdapter('./user-attachments');
// React Native — Expo (Expo 54+)
import { ExpoFileSystemStorageAdapter } from '@powersync/attachments-storage-react-native';
const localStorage = new ExpoFileSystemStorageAdapter();
// React Native — bare
import { ReactNativeFileSystemStorageAdapter } from '@powersync/attachments-storage-react-native';
const localStorage = new ReactNativeFileSystemStorageAdapter();
Remote Storage Adapter
Implement an object with uploadFile, downloadFile, and deleteFile. Always use signed URLs generated by your backend — never expose storage credentials to the client.
import type { AttachmentRecord } from '@powersync/web';
const remoteStorage = {
async uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord) {
const { uploadUrl } = await fetch('/api/attachments/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: attachment.filename, contentType: attachment.mediaType }),
}).then(r => r.json());
await fetch(uploadUrl, {
method: 'PUT',
body: fileData,
headers: { 'Content-Type': attachment.mediaType ?? 'application/octet-stream' },
});
},
async downloadFile(attachment: AttachmentRecord): Promise<ArrayBuffer> {
const { downloadUrl } = await fetch(`/api/attachments/${attachment.id}/download-url`).then(r => r.json());
return fetch(downloadUrl).then(r => r.arrayBuffer());
},
async deleteFile(attachment: AttachmentRecord) {
await fetch(`/api/attachments/${attachment.id}`, { method: 'DELETE' });
},
};
Initialize the Attachment Queue
import { AttachmentQueue } from '@powersync/web';
// or '@powersync/react-native' / '@powersync/node'
const attachmentQueue = new AttachmentQueue({
db, // PowerSyncDatabase instance
localStorage,
remoteStorage,
// Tell the queue which attachments your data model references.
// Called reactively whenever watched tables change.
watchAttachments: (onUpdate) => {
db.watch(
`SELECT photo_id FROM users WHERE photo_id IS NOT NULL`,
[],
{
onResult: async (result) => {
const attachments = result.rows?._array.map(row => ({
id: row.photo_id,
fileExtension: 'jpg',
})) ?? [];
await onUpdate(attachments);
},
}
);
},
syncIntervalMs: 30_000, // retry interval (default: 30s)
downloadAttachments: true, // auto-download referenced files (default: true)
archivedCacheLimit: 100, // archived files to keep before cleanup (default: 100)
});
await attachmentQueue.startSync();
watchAttachments is critical: it tells the queue which files the app needs based on the current data model. The queue uses its output to decide what to download, upload, or archive. Each item must include id and fileExtension.
Upload a File
Use saveFile(). The updateHook runs in the same database transaction as the attachment record creation, ensuring the FK in your data model and the attachment record are always written atomically.
async function uploadProfilePhoto(imageBlob: Blob, userId: string) {
const arrayBuffer = await imageBlob.arrayBuffer();
await attachmentQueue.saveFile({
data: arrayBuffer,
fileExtension: 'jpg',
mediaType: 'image/jpeg',
updateHook: async (tx, attachment) => {
// Runs in the same transaction — atomic
await tx.execute('UPDATE users SET photo_id = ? WHERE id = ?', [attachment.id, userId]);
},
});
}
Do not write the FK separately outside of
updateHook. Writing it in a separatedb.execute()call aftersaveFile()breaks atomicity and can leave the data model inconsistent if the app crashes between the two writes.
Delete a File
Explicit delete — use deleteFile() with an updateHook to clear the FK atomically:
await attachmentQueue.deleteFile({
id: photoId,
updateHook: async (tx, attachment) => {
await tx.execute('UPDATE users SET photo_id = NULL WHERE id = ?', [userId]);
},
});
// Queue will: delete from remote storage → delete local file → remove attachment record
Passive archive — remove the FK reference from your data model without calling deleteFile(). The watchAttachments query will no longer return the ID, so the queue automatically transitions the record to ARCHIVED. Once archivedCacheLimit is reached, archived files are deleted in order.
// Just clear the reference; the queue handles cleanup
await db.execute('UPDATE users SET photo_id = NULL WHERE id = ?', [userId]);
Accessing a File
Files are only available after the attachment record reaches SYNCED state. Read local_uri from the attachments table:
// React hook example — watches for the file to become available
function useProfilePhoto(userId: string) {
return useQuery<{ local_uri: string; state: string }>(
`SELECT a.local_uri, a.state
FROM users u
LEFT JOIN attachments a ON a.id = u.photo_id
WHERE u.id = ?`,
[userId]
);
}
// In your component:
const { data } = useProfilePhoto(userId);
const uri = data?.[0]?.state === 'SYNCED' ? data[0].local_uri : null;
For non-React targets, use db.watch() with the same query pattern.
Watching Multiple Attachment Types
Single queue with UNION ALL — simpler, but the query runs whenever any watched table changes:
watchAttachments: (onUpdate) => {
db.watch(
`SELECT photo_id AS id, 'jpg' AS file_extension FROM users WHERE photo_id IS NOT NULL
UNION ALL
SELECT document_id AS id, 'pdf' AS file_extension FROM documents WHERE document_id IS NOT NULL`,
[],
{
onResult: async (result) => {
const attachments = result.rows?._array.map(row => ({
id: row.id,
fileExtension: row.file_extension,
})) ?? [];
await onUpdate(attachments);
},
}
);
},
Multiple queues — each queue watches its own table with a simpler query; more memory, but independent configuration per attachment type.
Error Handling
Return true to retry on the next sync interval; return false to archive the attachment and stop retrying.
import type { AttachmentErrorHandler } from '@powersync/web';
const errorHandler: AttachmentErrorHandler = {
async onDownloadError(attachment, error) {
if (error.message.includes('404')) return false; // file gone — don't retry
return true;
},
async onUploadError(attachment, error) {
return true; // always retry uploads
},
async onDeleteError(attachment, error) {
return true;
},
};
const attachmentQueue = new AttachmentQueue({ /* ... */ errorHandler });
Key Rules to Apply Without Being Asked
updateHookis mandatory for atomicity — always link the attachment ID to your data model insideupdateHook, never in a separate write aftersaveFile().- Signed URLs only — the remote storage adapter must fetch signed URLs from your backend. Never embed storage credentials in the client app.
- React Native needs a separate local storage package — install
@powersync/attachments-storage-react-nativeand the appropriate file system peer (expo-file-systemfor Expo 54+,@dr.pogodin/react-native-fsfor bare RN). fileExtensionis required inwatchAttachments— each item returned bywatchAttachmentsmust include bothidandfileExtension; the queue uses the extension to name local files.- Check
state === 'SYNCED'before usinglocal_uri— the record exists before the file is downloaded; accessinglocal_uriwhen state isQUEUED_DOWNLOADwill point to a file that doesn't exist yet. - Migration from
@powersync/attachments— set a customviewNameonAttachmentTable(e.g.'attachment_queue') to avoid a SQLite conflict with the legacyattachmentstable. Also update:syncInterval→syncIntervalMs,cacheLimit→archivedCacheLimit,name→viewNameonAttachmentTable.