# Attachments utilities and functions

> [!NOTE]
> 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.

PowerSync utilities and classes managing file attachments in JavaScript/TypeScript applications. Automatically handles synchronization of files between local storage and remote storage (S3, Supabase Storage, etc.), with support for upload/download queuing, offline functionality, and cache management.

For detailed concepts and guides, see the [PowerSync documentation](https://docs.powersync.com/usage/use-case-examples/attachments-files).

## Quick Start

This example shows a web application where users have profile photos stored as attachments.

### 1. Add AttachmentTable to your schema

```typescript
import { Schema, Table, column, AttachmentTable } from '@powersync/web';

const appSchema = new Schema({
  users: new Table({
    name: column.text,
    email: column.text,
    photo_id: column.text
  }),
  attachments: new AttachmentTable()
});
```

### 2. Set up storage adapters

```typescript
import { IndexDBFileSystemStorageAdapter } from '@powersync/web';

// Local storage for the browser (IndexedDB)
const localStorage = new IndexDBFileSystemStorageAdapter('my-app-files');

// Remote storage adapter for your cloud storage (e.g., S3, Supabase)
const remoteStorage = {
  async uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord): Promise<void> {
    // Get signed upload URL from your backend
    const { uploadUrl } = await fetch('/api/attachments/upload-url', {
      method: 'POST',
      body: JSON.stringify({ filename: attachment.filename })
    }).then(r => r.json());
    
    // Upload file to cloud storage
    await fetch(uploadUrl, {
      method: 'PUT',
      body: fileData,
      headers: { 'Content-Type': attachment.mediaType || 'application/octet-stream' }
    });
  },
  
  async downloadFile(attachment: AttachmentRecord): Promise<ArrayBuffer> {
    // Get signed download URL from your backend
    const { downloadUrl } = await fetch(`/api/attachments/download-url/${attachment.id}`)
      .then(r => r.json());
    
    // Download file from cloud storage
    const response = await fetch(downloadUrl);
    return response.arrayBuffer();
  },
  
  async deleteFile(attachment: AttachmentRecord): Promise<void> {
    // Delete from cloud storage via your backend
    await fetch(`/api/attachments/${attachment.id}`, { method: 'DELETE' });
  }
};
```

> **Note:** For Node.js or Electron apps, use `NodeFileSystemAdapter` instead:
> ```typescript
> import { NodeFileSystemAdapter } from '@powersync/node';
> const localStorage = new NodeFileSystemAdapter('./user-attachments');
> ```

### 3. Create and start AttachmentQueue

```typescript
import { AttachmentQueue } from '@powersync/web';

const profilePicturesQueue = new AttachmentQueue({
  db: powersync,
  localStorage,
  remoteStorage,
  // Determine what attachments the queue should handle
  // in this case it handles only the user profile pictures
  watchAttachments: (onUpdate) => {
    powersync.watch(
      'SELECT photo_id FROM users WHERE photo_id IS NOT NULL',
      [],
      {
        onResult: (result) => {
          const attachments = result.rows?._array.map(row => ({
            id: row.photo_id,
            fileExtension: 'jpg'
          })) ?? [];
          onUpdate(attachments);
        }
      }
    );
  }
});

// Start automatic syncing
await profilePicturesQueue.startSync();
```

### 4. Save files with atomic updates

```typescript
// When user uploads a profile photo
async function uploadProfilePhoto(imageBlob: Blob) {
  const arrayBuffer = await imageBlob.arrayBuffer();
  
  const attachment = await queue.saveFile({
    data: arrayBuffer,
    fileExtension: 'jpg',
    mediaType: 'image/jpeg',
    // Atomically update the user record in the same transaction
    updateHook: async (tx, attachment) => {
      await tx.execute(
        'UPDATE users SET photo_id = ? WHERE id = ?',
        [attachment.id, currentUserId]
      );
    }
  });
  
  console.log('Photo queued for upload:', attachment.id);
  // File will automatically upload in the background
}
```

## Storage Adapters

### Local Storage Adapters

Local storage adapters handle file persistence on the device.

#### IndexDBFileSystemStorageAdapter

For web browsers using IndexedDB:

```typescript
import { IndexDBFileSystemStorageAdapter } from '@powersync/web';

const localStorage = new IndexDBFileSystemStorageAdapter('database-name');
```

**Constructor Parameters:**
- `databaseName` (string, optional): IndexedDB database name. Default: `'PowerSyncFiles'`

#### NodeFileSystemAdapter

For Node.js and Electron using Node filesystem:

```typescript
import { NodeFileSystemAdapter } from '@powersync/node';

const localStorage = new NodeFileSystemAdapter('./attachments');
```

### ExpoFileSystemAdapter

For React Native using Expo:

```typescript
import { ExpoFileSystemAdapter } from '@powersync/react-native'

const localeStorage = new ExpoFileSystemAdapter();
```

**Constructor Parameters:**
- `storageDirectory` (string, optional): Directory path for storing files. Default: `'./user_data'`

#### Custom Local Storage Adapter

Implement the `LocalStorageAdapter` interface for other environments:

```typescript
interface LocalStorageAdapter {
  initialize(): Promise<void>;
  clear(): Promise<void>;
  getLocalUri(filename: string): string;
  saveFile(filePath: string, data: ArrayBuffer | string): Promise<number>;
  readFile(filePath: string): Promise<ArrayBuffer>;
  deleteFile(filePath: string): Promise<void>;
  fileExists(filePath: string): Promise<boolean>;
  makeDir(path: string): Promise<void>;
  rmDir(path: string): Promise<void>;
}
```

### Remote Storage Adapter

Remote storage adapters handle communication with your cloud storage (S3, Supabase Storage, Cloudflare R2, etc.).

#### Interface

```typescript
interface RemoteStorageAdapter {
  uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord): Promise<void>;
  downloadFile(attachment: AttachmentRecord): Promise<ArrayBuffer>;
  deleteFile(attachment: AttachmentRecord): Promise<void>;
}
```

#### Example: S3-Compatible Storage with Signed URLs

```typescript
import { RemoteStorageAdapter, AttachmentRecord } from '@powersync/web';

const remoteStorage: RemoteStorageAdapter = {
  async uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord): Promise<void> {
    // Request signed upload URL from your backend
    const response = await fetch('https://api.example.com/attachments/upload-url', {
      method: 'POST',
      headers: { 
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${getAuthToken()}`
      },
      body: JSON.stringify({ 
        filename: attachment.filename,
        contentType: attachment.mediaType 
      })
    });
    
    const { uploadUrl } = await response.json();
    
    // Upload directly to S3 using signed URL
    await fetch(uploadUrl, {
      method: 'PUT',
      body: fileData,
      headers: { 'Content-Type': attachment.mediaType || 'application/octet-stream' }
    });
  },

  async downloadFile(attachment: AttachmentRecord): Promise<ArrayBuffer> {
    // Request signed download URL from your backend
    const response = await fetch(
      `https://api.example.com/attachments/${attachment.id}/download-url`,
      { headers: { 'Authorization': `Bearer ${getAuthToken()}` } }
    );
    
    const { downloadUrl } = await response.json();
    
    // Download from S3 using signed URL
    const fileResponse = await fetch(downloadUrl);
    if (!fileResponse.ok) {
      throw new Error(`Download failed: ${fileResponse.statusText}`);
    }
    
    return fileResponse.arrayBuffer();
  },

  async deleteFile(attachment: AttachmentRecord): Promise<void> {
    // Delete via your backend (backend handles S3 deletion)
    await fetch(`https://api.example.com/attachments/${attachment.id}`, {
      method: 'DELETE',
      headers: { 'Authorization': `Bearer ${getAuthToken()}` }
    });
  }
};
```

> **Security Note:** Always use your backend to generate signed URLs and validate permissions. Never expose storage credentials to the client.

## API Reference

### AttachmentQueue

Main class for managing attachment synchronization.

#### Constructor

```typescript
new AttachmentQueue(options: AttachmentQueueOptions)
```

**Options:**

| Parameter | Type | Required | Default | Description |
|-----------|------|----------|---------|-------------|
| `db` | `AbstractPowerSyncDatabase` | Yes | - | PowerSync database instance |
| `remoteStorage` | `RemoteStorageAdapter` | Yes | - | Remote storage adapter implementation |
| `localStorage` | `LocalStorageAdapter` | Yes | - | Local storage adapter implementation |
| `watchAttachments` | `(onUpdate: (attachments: WatchedAttachmentItem[]) => Promise<void>) => void` | Yes | - | Callback to determine which attachments to handle by the queue from your user defined query |
| `tableName` | `string` | No | `'attachments'` | Name of the attachments table |
| `logger` | `ILogger` | No | `db.logger` | Logger instance for diagnostic output |
| `syncIntervalMs` | `number` | No | `30000` | Periodic polling interval (in milliseconds) for retrying failed uploads/downloads. A `setInterval` timer that calls `syncStorage()` on this cadence, ensuring operations are retried even if no database changes occur (e.g., after coming back online). |
| `syncThrottleDuration` | `number` | No | `30` | Throttle duration (in milliseconds) for the reactive watch query on the attachments table. When attachment records change (e.g., a new file is queued), a watch query detects the change and triggers a sync. This throttle prevents the sync from firing too rapidly when many changes happen in quick succession (e.g., bulk inserts). This is distinct from `syncIntervalMs` — it controls how quickly the queue *reacts* to changes, while `syncIntervalMs` controls how often it *polls* for retries. |
| `downloadAttachments` | `boolean` | No | `true` | Whether to automatically download remote attachments |
| `archivedCacheLimit` | `number` | No | `100` | Maximum number of archived attachments before cleanup |
| `errorHandler` | `AttachmentErrorHandler` | No | `undefined` | Custom error handler for upload/download/delete operations |

#### Methods

##### `startSync()`

Starts automatic attachment synchronization.

```typescript
await queue.startSync();
```

This will:
- Initialize local storage
- Set up periodic sync based on `syncIntervalMs`
- Watch for changes in active attachments
- Process queued uploads, downloads, and deletes

##### `stopSync()`

Stops automatic attachment synchronization.

```typescript
await queue.stopSync();
```

##### `saveFile(options)`

Saves a file locally and queues it for upload to remote storage.

```typescript
const attachment = await queue.saveFile({
  data: arrayBuffer,
  fileExtension: 'pdf',
  mediaType: 'application/pdf',
  id: 'custom-id', // optional
  metaData: '{"description": "Invoice"}', // optional
  updateHook: async (tx, attachment) => {
    // Update your data model in the same transaction
    await tx.execute(
      'INSERT INTO documents (id, attachment_id) VALUES (?, ?)',
      [documentId, attachment.id]
    );
  }
});
```

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `data` | `ArrayBuffer \| string` | Yes | File data as ArrayBuffer or base64 string |
| `fileExtension` | `string` | Yes | File extension (e.g., 'jpg', 'pdf') |
| `mediaType` | `string` | No | MIME type (e.g., 'image/jpeg') |
| `id` | `string` | No | Custom attachment ID (UUID generated if not provided) |
| `metaData` | `string` | No | Optional metadata JSON string |
| `updateHook` | `(tx: Transaction, attachment: AttachmentRecord) => Promise<void>` | No | Callback to update your data model atomically |

**Returns:** `Promise<AttachmentRecord>` - The created attachment record

The `updateHook` is executed in the same database transaction as the attachment creation, ensuring atomic operations. This is the recommended way to link attachments to your data model.

##### `deleteFile(options)`

Deletes an attachment from both local and remote storage.

```typescript
await queue.deleteFile({
  id: attachmentId,
  updateHook: async (tx, attachment) => {
    // Update your data model in the same transaction
    await tx.execute(
      'UPDATE users SET photo_id = NULL WHERE photo_id = ?',
      [attachment.id]
    );
  }
});
```

**Parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | `string` | Yes | Attachment ID to delete |
| `updateHook` | `(tx: Transaction, attachment: AttachmentRecord) => Promise<void>` | No | Callback to update your data model atomically |

##### `generateAttachmentId()`

Generates a new UUID for an attachment using SQLite's `uuid()` function.

```typescript
const id = await queue.generateAttachmentId();
```

**Returns:** `Promise<string>` - A new UUID

##### `syncStorage()`

Manually triggers a sync operation. This is called automatically at regular intervals, but can be invoked manually if needed.

```typescript
await queue.syncStorage();
```

##### `verifyAttachments()`

Verifies the integrity of all attachment records and repairs inconsistencies. Checks each attachment against local storage and:
- Updates `localUri` if file exists at a different path
- Archives attachments with missing local files that haven't been uploaded
- Requeues synced attachments for download if local files are missing

```typescript
await queue.verifyAttachments();
```

This is automatically called when `startSync()` is invoked.

##### `watchAttachments` callback

The `watchAttachments` callback is a required parameter that tells the AttachmentQueue which attachments to handle. This tells the queue which attachments to download, upload, or archive.

**Signature:**

```typescript
(onUpdate: (attachments: WatchedAttachmentItem[]) => Promise<void>) => void
```

**WatchedAttachmentItem:**

```typescript
type WatchedAttachmentItem = {
  id: string;
  fileExtension: string;  // e.g., 'jpg', 'pdf'
  metaData?: string;
} | {
  id: string;
  filename: string;       // e.g., 'document.pdf'
  metaData?: string;
};
```

Use either `fileExtension` OR `filename`, not both.

**Example:**

```typescript
watchAttachments: (onUpdate) => {
  // Watch for photo references in users table
  db.watch(
    'SELECT photo_id, metadata FROM users WHERE photo_id IS NOT NULL',
    [],
    {
      onResult: async (result) => {
        const attachments = result.rows?._array.map(row => ({
          id: row.photo_id,
          fileExtension: 'jpg',
          metaData: row.metadata
        })) ?? [];
        await onUpdate(attachments);
      }
    }
  );
}
```

---

### AttachmentTable

PowerSync schema table for storing attachment metadata.

#### Constructor

```typescript
new AttachmentTable(options?: AttachmentTableOptions)
```

**Options:**

Extends PowerSync `TableV2Options` (excluding `name` and `columns`).

| Parameter | Type | Description |
|-----------|------|-------------|
| `viewName` | `string` | View name for the table. Default: `'attachments'` |
| `localOnly` | `boolean` | Whether table is local-only. Default: `true` |
| `insertOnly` | `boolean` | Whether table is insert-only. Default: `false` |

#### Default Columns

| Column | Type | Description |
|--------|------|-------------|
| `id` | `TEXT` | Attachment ID (primary key) |
| `filename` | `TEXT` | Filename with extension |
| `local_uri` | `TEXT` | Local file path or URI |
| `timestamp` | `INTEGER` | Last update timestamp |
| `size` | `INTEGER` | File size in bytes |
| `media_type` | `TEXT` | MIME type |
| `state` | `INTEGER` | Sync state (see `AttachmentState`) |
| `has_synced` | `INTEGER` | Whether file has synced (0 or 1) |
| `meta_data` | `TEXT` | Optional metadata JSON string |

---

### AttachmentRecord

Interface representing an attachment record.

```typescript
interface AttachmentRecord {
  id: string;
  filename: string;
  localUri?: string;
  size?: number;
  mediaType?: string;
  timestamp?: number;
  metaData?: string;
  hasSynced?: boolean;
  state: AttachmentState;
}
```

---

### AttachmentState

Enum representing attachment synchronization states.

```typescript
enum AttachmentState {
  QUEUED_UPLOAD = 0,    // Queued for upload
  QUEUED_DOWNLOAD = 1,  // Queued for download
  QUEUED_DELETE = 2,    // Queued for deletion
  SYNCED = 3,           // Successfully synced
  ARCHIVED = 4          // No longer referenced (orphaned)
}
```

---

### LocalStorageAdapter

Interface for local file storage operations.

```typescript
interface LocalStorageAdapter {
  initialize(): Promise<void>;
  clear(): Promise<void>;
  getLocalUri(filename: string): string;
  saveFile(filePath: string, data: ArrayBuffer | string): Promise<number>;
  readFile(filePath: string): Promise<ArrayBuffer>;
  deleteFile(filePath: string): Promise<void>;
  fileExists(filePath: string): Promise<boolean>;
  makeDir(path: string): Promise<void>;
  rmDir(path: string): Promise<void>;
}
```

---

### RemoteStorageAdapter

Interface for remote storage operations.

```typescript
interface RemoteStorageAdapter {
  uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord): Promise<void>;
  downloadFile(attachment: AttachmentRecord): Promise<ArrayBuffer>;
  deleteFile(attachment: AttachmentRecord): Promise<void>;
}
```

---

### NodeFileSystemAdapter

Local storage adapter for Node.js and Electron.

**Constructor:**

```typescript
new NodeFileSystemAdapter(storageDirectory?: string)
```

- `storageDirectory` (optional): Directory path for storing files. Default: `'./user_data'`

---

### IndexDBFileSystemStorageAdapter

Local storage adapter for web browsers using IndexedDB.

**Constructor:**

```typescript
new IndexDBFileSystemStorageAdapter(databaseName?: string)
```

- `databaseName` (optional): IndexedDB database name. Default: `'PowerSyncFiles'`

## Error Handling

The `AttachmentErrorHandler` interface allows you to customize error handling for sync operations.

### Interface

```typescript
interface AttachmentErrorHandler {
  onDownloadError(attachment: AttachmentRecord, error: Error): Promise<boolean>;
  onUploadError(attachment: AttachmentRecord, error: Error): Promise<boolean>;
  onDeleteError(attachment: AttachmentRecord, error: Error): Promise<boolean>;
}
```

Each method returns:
- `true` to retry the operation
- `false` to archive the attachment and skip retrying

### Example

```typescript
const errorHandler: AttachmentErrorHandler = {
  async onDownloadError(attachment, error) {
    console.error(`Download failed for ${attachment.filename}:`, error);
    
    // Retry on network errors, archive on 404s
    if (error.message.includes('404') || error.message.includes('Not Found')) {
      console.log('File not found, archiving attachment');
      return false; // Archive
    }
    
    console.log('Will retry download on next sync');
    return true; // Retry
  },

  async onUploadError(attachment, error) {
    console.error(`Upload failed for ${attachment.filename}:`, error);
    
    // Always retry uploads
    return true;
  },

  async onDeleteError(attachment, error) {
    console.error(`Delete failed for ${attachment.filename}:`, error);
    
    // Retry deletes, but archive after too many attempts
    const attempts = attachment.metaData ? 
      JSON.parse(attachment.metaData).deleteAttempts || 0 : 0;
    
    return attempts < 3; // Retry up to 3 times
  }
};

const queue = new AttachmentQueue({
  // ... other options
  errorHandler
});
```

## Advanced Usage

### Verification and Recovery

The `verifyAttachments()` method checks attachment integrity and repairs issues:

```typescript
// Manually verify all attachments
await queue.verifyAttachments();
```

This is useful if:
- Local files may have been manually deleted
- Storage paths changed
- You suspect data inconsistencies

Verification is automatically run when `startSync()` is called.

### Custom Sync Intervals

Adjust sync frequency based on your needs:

```typescript
const queue = new AttachmentQueue({
  // ... other options
  syncIntervalMs: 60000, // Poll for retries every 60 seconds instead of 30
  syncThrottleDuration: 100, // React to attachment changes within 100ms (default: 30ms)
});
```

- **`syncIntervalMs`** controls the periodic polling timer — how often the queue retries failed operations.
- **`syncThrottleDuration`** controls how quickly the queue reacts to attachment table changes. The default (30ms) is fast enough for most use cases. Increase it if you see performance issues during bulk attachment operations.

### Archive and Cache Management

Control how many archived attachments are kept before cleanup:

```typescript
const queue = new AttachmentQueue({
  // ... other options
  archivedCacheLimit: 200, // Keep up to 200 archived attachments
});
```

Archived attachments are those no longer referenced in your data model but not yet deleted. This allows for:
- Quick restoration if references are added back
- Caching of recently used files
- Gradual cleanup to avoid storage bloat

When the limit is reached, the oldest archived attachments are permanently deleted.

## Examples

See the following demo applications in this repository:

- **[react-native-supabase-todolist](../../../../demos/react-native-supabase-todolist)** - React Native mobile app with attachment support
- **[react-native-web-supabase-todolist](../../../../demos/react-native-web-supabase-todolist)** - React Native web app with Supabase Storage integration

Each demo shows a complete implementation including:
- Schema setup
- Storage adapter configuration
- File upload/download UI
- Error handling

## License

Apache 2.0
