@powersync/common 1.46.0 → 1.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +5 -1
  2. package/dist/bundle.cjs +1298 -395
  3. package/dist/bundle.cjs.map +1 -1
  4. package/dist/bundle.mjs +1291 -395
  5. package/dist/bundle.mjs.map +1 -1
  6. package/dist/bundle.node.cjs +1298 -395
  7. package/dist/bundle.node.cjs.map +1 -1
  8. package/dist/bundle.node.mjs +1291 -395
  9. package/dist/bundle.node.mjs.map +1 -1
  10. package/dist/index.d.cts +652 -106
  11. package/lib/attachments/AttachmentContext.d.ts +86 -0
  12. package/lib/attachments/AttachmentContext.js +229 -0
  13. package/lib/attachments/AttachmentContext.js.map +1 -0
  14. package/lib/attachments/AttachmentErrorHandler.d.ts +31 -0
  15. package/lib/attachments/AttachmentErrorHandler.js +2 -0
  16. package/lib/attachments/AttachmentErrorHandler.js.map +1 -0
  17. package/lib/attachments/AttachmentQueue.d.ts +149 -0
  18. package/lib/attachments/AttachmentQueue.js +362 -0
  19. package/lib/attachments/AttachmentQueue.js.map +1 -0
  20. package/lib/attachments/AttachmentService.d.ts +29 -0
  21. package/lib/attachments/AttachmentService.js +56 -0
  22. package/lib/attachments/AttachmentService.js.map +1 -0
  23. package/lib/attachments/LocalStorageAdapter.d.ts +62 -0
  24. package/lib/attachments/LocalStorageAdapter.js +6 -0
  25. package/lib/attachments/LocalStorageAdapter.js.map +1 -0
  26. package/lib/attachments/RemoteStorageAdapter.d.ts +27 -0
  27. package/lib/attachments/RemoteStorageAdapter.js +2 -0
  28. package/lib/attachments/RemoteStorageAdapter.js.map +1 -0
  29. package/lib/attachments/Schema.d.ts +50 -0
  30. package/lib/attachments/Schema.js +62 -0
  31. package/lib/attachments/Schema.js.map +1 -0
  32. package/lib/attachments/SyncingService.d.ts +62 -0
  33. package/lib/attachments/SyncingService.js +168 -0
  34. package/lib/attachments/SyncingService.js.map +1 -0
  35. package/lib/attachments/WatchedAttachmentItem.d.ts +17 -0
  36. package/lib/attachments/WatchedAttachmentItem.js +2 -0
  37. package/lib/attachments/WatchedAttachmentItem.js.map +1 -0
  38. package/lib/db/schema/RawTable.d.ts +61 -26
  39. package/lib/db/schema/RawTable.js +1 -32
  40. package/lib/db/schema/RawTable.js.map +1 -1
  41. package/lib/db/schema/Schema.d.ts +14 -7
  42. package/lib/db/schema/Schema.js +25 -3
  43. package/lib/db/schema/Schema.js.map +1 -1
  44. package/lib/db/schema/Table.d.ts +13 -8
  45. package/lib/db/schema/Table.js +3 -8
  46. package/lib/db/schema/Table.js.map +1 -1
  47. package/lib/db/schema/internal.d.ts +12 -0
  48. package/lib/db/schema/internal.js +15 -0
  49. package/lib/db/schema/internal.js.map +1 -0
  50. package/lib/index.d.ts +11 -1
  51. package/lib/index.js +10 -1
  52. package/lib/index.js.map +1 -1
  53. package/lib/utils/mutex.d.ts +1 -1
  54. package/lib/utils/mutex.js.map +1 -1
  55. package/package.json +1 -1
  56. package/src/attachments/AttachmentContext.ts +279 -0
  57. package/src/attachments/AttachmentErrorHandler.ts +34 -0
  58. package/src/attachments/AttachmentQueue.ts +472 -0
  59. package/src/attachments/AttachmentService.ts +62 -0
  60. package/src/attachments/LocalStorageAdapter.ts +72 -0
  61. package/src/attachments/README.md +718 -0
  62. package/src/attachments/RemoteStorageAdapter.ts +30 -0
  63. package/src/attachments/Schema.ts +87 -0
  64. package/src/attachments/SyncingService.ts +193 -0
  65. package/src/attachments/WatchedAttachmentItem.ts +19 -0
  66. package/src/db/schema/RawTable.ts +66 -31
  67. package/src/db/schema/Schema.ts +27 -2
  68. package/src/db/schema/Table.ts +11 -11
  69. package/src/db/schema/internal.ts +17 -0
  70. package/src/index.ts +12 -1
  71. package/src/utils/mutex.ts +1 -1
@@ -0,0 +1,718 @@
1
+ # Attachments utilities and functions
2
+
3
+ > [!NOTE]
4
+ > Attachment helpers are currently in an **alpha** state, intended strictly for testing. Expect breaking changes and instability as development continues.
5
+ >
6
+ > Do not rely on this package for production use.
7
+
8
+ 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.
9
+
10
+ For detailed concepts and guides, see the [PowerSync documentation](https://docs.powersync.com/usage/use-case-examples/attachments-files).
11
+
12
+ ## Quick Start
13
+
14
+ This example shows a web application where users have profile photos stored as attachments.
15
+
16
+ ### 1. Add AttachmentTable to your schema
17
+
18
+ ```typescript
19
+ import { Schema, Table, column, AttachmentTable } from '@powersync/web';
20
+
21
+ const appSchema = new Schema({
22
+ users: new Table({
23
+ name: column.text,
24
+ email: column.text,
25
+ photo_id: column.text
26
+ }),
27
+ attachments: new AttachmentTable()
28
+ });
29
+ ```
30
+
31
+ ### 2. Set up storage adapters
32
+
33
+ ```typescript
34
+ import { IndexDBFileSystemStorageAdapter } from '@powersync/web';
35
+
36
+ // Local storage for the browser (IndexedDB)
37
+ const localStorage = new IndexDBFileSystemStorageAdapter('my-app-files');
38
+
39
+ // Remote storage adapter for your cloud storage (e.g., S3, Supabase)
40
+ const remoteStorage = {
41
+ async uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord): Promise<void> {
42
+ // Get signed upload URL from your backend
43
+ const { uploadUrl } = await fetch('/api/attachments/upload-url', {
44
+ method: 'POST',
45
+ body: JSON.stringify({ filename: attachment.filename })
46
+ }).then(r => r.json());
47
+
48
+ // Upload file to cloud storage
49
+ await fetch(uploadUrl, {
50
+ method: 'PUT',
51
+ body: fileData,
52
+ headers: { 'Content-Type': attachment.mediaType || 'application/octet-stream' }
53
+ });
54
+ },
55
+
56
+ async downloadFile(attachment: AttachmentRecord): Promise<ArrayBuffer> {
57
+ // Get signed download URL from your backend
58
+ const { downloadUrl } = await fetch(`/api/attachments/download-url/${attachment.id}`)
59
+ .then(r => r.json());
60
+
61
+ // Download file from cloud storage
62
+ const response = await fetch(downloadUrl);
63
+ return response.arrayBuffer();
64
+ },
65
+
66
+ async deleteFile(attachment: AttachmentRecord): Promise<void> {
67
+ // Delete from cloud storage via your backend
68
+ await fetch(`/api/attachments/${attachment.id}`, { method: 'DELETE' });
69
+ }
70
+ };
71
+ ```
72
+
73
+ > **Note:** For Node.js or Electron apps, use `NodeFileSystemAdapter` instead:
74
+ > ```typescript
75
+ > import { NodeFileSystemAdapter } from '@powersync/node';
76
+ > const localStorage = new NodeFileSystemAdapter('./user-attachments');
77
+ > ```
78
+
79
+ ### 3. Create and start AttachmentQueue
80
+
81
+ ```typescript
82
+ import { AttachmentQueue } from '@powersync/web';
83
+
84
+ const profilePicturesQueue = new AttachmentQueue({
85
+ db: powersync,
86
+ localStorage,
87
+ remoteStorage,
88
+ // Determine what attachments the queue should handle
89
+ // in this case it handles only the user profile pictures
90
+ watchAttachments: (onUpdate) => {
91
+ powersync.watch(
92
+ 'SELECT photo_id FROM users WHERE photo_id IS NOT NULL',
93
+ [],
94
+ {
95
+ onResult: (result) => {
96
+ const attachments = result.rows?._array.map(row => ({
97
+ id: row.photo_id,
98
+ fileExtension: 'jpg'
99
+ })) ?? [];
100
+ onUpdate(attachments);
101
+ }
102
+ }
103
+ );
104
+ }
105
+ });
106
+
107
+ // Start automatic syncing
108
+ await profilePicturesQueue.startSync();
109
+ ```
110
+
111
+ ### 4. Save files with atomic updates
112
+
113
+ ```typescript
114
+ // When user uploads a profile photo
115
+ async function uploadProfilePhoto(imageBlob: Blob) {
116
+ const arrayBuffer = await imageBlob.arrayBuffer();
117
+
118
+ const attachment = await queue.saveFile({
119
+ data: arrayBuffer,
120
+ fileExtension: 'jpg',
121
+ mediaType: 'image/jpeg',
122
+ // Atomically update the user record in the same transaction
123
+ updateHook: async (tx, attachment) => {
124
+ await tx.execute(
125
+ 'UPDATE users SET photo_id = ? WHERE id = ?',
126
+ [attachment.id, currentUserId]
127
+ );
128
+ }
129
+ });
130
+
131
+ console.log('Photo queued for upload:', attachment.id);
132
+ // File will automatically upload in the background
133
+ }
134
+ ```
135
+
136
+ ## Storage Adapters
137
+
138
+ ### Local Storage Adapters
139
+
140
+ Local storage adapters handle file persistence on the device.
141
+
142
+ #### IndexDBFileSystemStorageAdapter
143
+
144
+ For web browsers using IndexedDB:
145
+
146
+ ```typescript
147
+ import { IndexDBFileSystemStorageAdapter } from '@powersync/web';
148
+
149
+ const localStorage = new IndexDBFileSystemStorageAdapter('database-name');
150
+ ```
151
+
152
+ **Constructor Parameters:**
153
+ - `databaseName` (string, optional): IndexedDB database name. Default: `'PowerSyncFiles'`
154
+
155
+ #### NodeFileSystemAdapter
156
+
157
+ For Node.js and Electron using Node filesystem:
158
+
159
+ ```typescript
160
+ import { NodeFileSystemAdapter } from '@powersync/node';
161
+
162
+ const localStorage = new NodeFileSystemAdapter('./attachments');
163
+ ```
164
+
165
+ ### ExpoFileSystemAdapter
166
+
167
+ For React Native using Expo:
168
+
169
+ ```typescript
170
+ import { ExpoFileSystemAdapter } from '@powersync/react-native'
171
+
172
+ const localeStorage = new ExpoFileSystemAdapter();
173
+ ```
174
+
175
+ **Constructor Parameters:**
176
+ - `storageDirectory` (string, optional): Directory path for storing files. Default: `'./user_data'`
177
+
178
+ #### Custom Local Storage Adapter
179
+
180
+ Implement the `LocalStorageAdapter` interface for other environments:
181
+
182
+ ```typescript
183
+ interface LocalStorageAdapter {
184
+ initialize(): Promise<void>;
185
+ clear(): Promise<void>;
186
+ getLocalUri(filename: string): string;
187
+ saveFile(filePath: string, data: ArrayBuffer | string): Promise<number>;
188
+ readFile(filePath: string): Promise<ArrayBuffer>;
189
+ deleteFile(filePath: string): Promise<void>;
190
+ fileExists(filePath: string): Promise<boolean>;
191
+ makeDir(path: string): Promise<void>;
192
+ rmDir(path: string): Promise<void>;
193
+ }
194
+ ```
195
+
196
+ ### Remote Storage Adapter
197
+
198
+ Remote storage adapters handle communication with your cloud storage (S3, Supabase Storage, Cloudflare R2, etc.).
199
+
200
+ #### Interface
201
+
202
+ ```typescript
203
+ interface RemoteStorageAdapter {
204
+ uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord): Promise<void>;
205
+ downloadFile(attachment: AttachmentRecord): Promise<ArrayBuffer>;
206
+ deleteFile(attachment: AttachmentRecord): Promise<void>;
207
+ }
208
+ ```
209
+
210
+ #### Example: S3-Compatible Storage with Signed URLs
211
+
212
+ ```typescript
213
+ import { RemoteStorageAdapter, AttachmentRecord } from '@powersync/web';
214
+
215
+ const remoteStorage: RemoteStorageAdapter = {
216
+ async uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord): Promise<void> {
217
+ // Request signed upload URL from your backend
218
+ const response = await fetch('https://api.example.com/attachments/upload-url', {
219
+ method: 'POST',
220
+ headers: {
221
+ 'Content-Type': 'application/json',
222
+ 'Authorization': `Bearer ${getAuthToken()}`
223
+ },
224
+ body: JSON.stringify({
225
+ filename: attachment.filename,
226
+ contentType: attachment.mediaType
227
+ })
228
+ });
229
+
230
+ const { uploadUrl } = await response.json();
231
+
232
+ // Upload directly to S3 using signed URL
233
+ await fetch(uploadUrl, {
234
+ method: 'PUT',
235
+ body: fileData,
236
+ headers: { 'Content-Type': attachment.mediaType || 'application/octet-stream' }
237
+ });
238
+ },
239
+
240
+ async downloadFile(attachment: AttachmentRecord): Promise<ArrayBuffer> {
241
+ // Request signed download URL from your backend
242
+ const response = await fetch(
243
+ `https://api.example.com/attachments/${attachment.id}/download-url`,
244
+ { headers: { 'Authorization': `Bearer ${getAuthToken()}` } }
245
+ );
246
+
247
+ const { downloadUrl } = await response.json();
248
+
249
+ // Download from S3 using signed URL
250
+ const fileResponse = await fetch(downloadUrl);
251
+ if (!fileResponse.ok) {
252
+ throw new Error(`Download failed: ${fileResponse.statusText}`);
253
+ }
254
+
255
+ return fileResponse.arrayBuffer();
256
+ },
257
+
258
+ async deleteFile(attachment: AttachmentRecord): Promise<void> {
259
+ // Delete via your backend (backend handles S3 deletion)
260
+ await fetch(`https://api.example.com/attachments/${attachment.id}`, {
261
+ method: 'DELETE',
262
+ headers: { 'Authorization': `Bearer ${getAuthToken()}` }
263
+ });
264
+ }
265
+ };
266
+ ```
267
+
268
+ > **Security Note:** Always use your backend to generate signed URLs and validate permissions. Never expose storage credentials to the client.
269
+
270
+ ## API Reference
271
+
272
+ ### AttachmentQueue
273
+
274
+ Main class for managing attachment synchronization.
275
+
276
+ #### Constructor
277
+
278
+ ```typescript
279
+ new AttachmentQueue(options: AttachmentQueueOptions)
280
+ ```
281
+
282
+ **Options:**
283
+
284
+ | Parameter | Type | Required | Default | Description |
285
+ |-----------|------|----------|---------|-------------|
286
+ | `db` | `AbstractPowerSyncDatabase` | Yes | - | PowerSync database instance |
287
+ | `remoteStorage` | `RemoteStorageAdapter` | Yes | - | Remote storage adapter implementation |
288
+ | `localStorage` | `LocalStorageAdapter` | Yes | - | Local storage adapter implementation |
289
+ | `watchAttachments` | `(onUpdate: (attachments: WatchedAttachmentItem[]) => Promise<void>) => void` | Yes | - | Callback to determine which attachments to handle by the queue from your user defined query |
290
+ | `tableName` | `string` | No | `'attachments'` | Name of the attachments table |
291
+ | `logger` | `ILogger` | No | `db.logger` | Logger instance for diagnostic output |
292
+ | `syncIntervalMs` | `number` | No | `30000` | Interval between automatic syncs in milliseconds |
293
+ | `syncThrottleDuration` | `number` | No | `30` | Throttle duration for sync operations in milliseconds |
294
+ | `downloadAttachments` | `boolean` | No | `true` | Whether to automatically download remote attachments |
295
+ | `archivedCacheLimit` | `number` | No | `100` | Maximum number of archived attachments before cleanup |
296
+ | `errorHandler` | `AttachmentErrorHandler` | No | `undefined` | Custom error handler for upload/download/delete operations |
297
+
298
+ #### Methods
299
+
300
+ ##### `startSync()`
301
+
302
+ Starts automatic attachment synchronization.
303
+
304
+ ```typescript
305
+ await queue.startSync();
306
+ ```
307
+
308
+ This will:
309
+ - Initialize local storage
310
+ - Set up periodic sync based on `syncIntervalMs`
311
+ - Watch for changes in active attachments
312
+ - Process queued uploads, downloads, and deletes
313
+
314
+ ##### `stopSync()`
315
+
316
+ Stops automatic attachment synchronization.
317
+
318
+ ```typescript
319
+ await queue.stopSync();
320
+ ```
321
+
322
+ ##### `saveFile(options)`
323
+
324
+ Saves a file locally and queues it for upload to remote storage.
325
+
326
+ ```typescript
327
+ const attachment = await queue.saveFile({
328
+ data: arrayBuffer,
329
+ fileExtension: 'pdf',
330
+ mediaType: 'application/pdf',
331
+ id: 'custom-id', // optional
332
+ metaData: '{"description": "Invoice"}', // optional
333
+ updateHook: async (tx, attachment) => {
334
+ // Update your data model in the same transaction
335
+ await tx.execute(
336
+ 'INSERT INTO documents (id, attachment_id) VALUES (?, ?)',
337
+ [documentId, attachment.id]
338
+ );
339
+ }
340
+ });
341
+ ```
342
+
343
+ **Parameters:**
344
+
345
+ | Parameter | Type | Required | Description |
346
+ |-----------|------|----------|-------------|
347
+ | `data` | `ArrayBuffer \| string` | Yes | File data as ArrayBuffer or base64 string |
348
+ | `fileExtension` | `string` | Yes | File extension (e.g., 'jpg', 'pdf') |
349
+ | `mediaType` | `string` | No | MIME type (e.g., 'image/jpeg') |
350
+ | `id` | `string` | No | Custom attachment ID (UUID generated if not provided) |
351
+ | `metaData` | `string` | No | Optional metadata JSON string |
352
+ | `updateHook` | `(tx: Transaction, attachment: AttachmentRecord) => Promise<void>` | No | Callback to update your data model atomically |
353
+
354
+ **Returns:** `Promise<AttachmentRecord>` - The created attachment record
355
+
356
+ 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.
357
+
358
+ ##### `deleteFile(options)`
359
+
360
+ Deletes an attachment from both local and remote storage.
361
+
362
+ ```typescript
363
+ await queue.deleteFile({
364
+ id: attachmentId,
365
+ updateHook: async (tx, attachment) => {
366
+ // Update your data model in the same transaction
367
+ await tx.execute(
368
+ 'UPDATE users SET photo_id = NULL WHERE photo_id = ?',
369
+ [attachment.id]
370
+ );
371
+ }
372
+ });
373
+ ```
374
+
375
+ **Parameters:**
376
+
377
+ | Parameter | Type | Required | Description |
378
+ |-----------|------|----------|-------------|
379
+ | `id` | `string` | Yes | Attachment ID to delete |
380
+ | `updateHook` | `(tx: Transaction, attachment: AttachmentRecord) => Promise<void>` | No | Callback to update your data model atomically |
381
+
382
+ ##### `generateAttachmentId()`
383
+
384
+ Generates a new UUID for an attachment using SQLite's `uuid()` function.
385
+
386
+ ```typescript
387
+ const id = await queue.generateAttachmentId();
388
+ ```
389
+
390
+ **Returns:** `Promise<string>` - A new UUID
391
+
392
+ ##### `syncStorage()`
393
+
394
+ Manually triggers a sync operation. This is called automatically at regular intervals, but can be invoked manually if needed.
395
+
396
+ ```typescript
397
+ await queue.syncStorage();
398
+ ```
399
+
400
+ ##### `verifyAttachments()`
401
+
402
+ Verifies the integrity of all attachment records and repairs inconsistencies. Checks each attachment against local storage and:
403
+ - Updates `localUri` if file exists at a different path
404
+ - Archives attachments with missing local files that haven't been uploaded
405
+ - Requeues synced attachments for download if local files are missing
406
+
407
+ ```typescript
408
+ await queue.verifyAttachments();
409
+ ```
410
+
411
+ This is automatically called when `startSync()` is invoked.
412
+
413
+ ##### `watchAttachments` callback
414
+
415
+ 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.
416
+
417
+ **Signature:**
418
+
419
+ ```typescript
420
+ (onUpdate: (attachments: WatchedAttachmentItem[]) => Promise<void>) => void
421
+ ```
422
+
423
+ **WatchedAttachmentItem:**
424
+
425
+ ```typescript
426
+ type WatchedAttachmentItem = {
427
+ id: string;
428
+ fileExtension: string; // e.g., 'jpg', 'pdf'
429
+ metaData?: string;
430
+ } | {
431
+ id: string;
432
+ filename: string; // e.g., 'document.pdf'
433
+ metaData?: string;
434
+ };
435
+ ```
436
+
437
+ Use either `fileExtension` OR `filename`, not both.
438
+
439
+ **Example:**
440
+
441
+ ```typescript
442
+ watchAttachments: (onUpdate) => {
443
+ // Watch for photo references in users table
444
+ db.watch(
445
+ 'SELECT photo_id, metadata FROM users WHERE photo_id IS NOT NULL',
446
+ [],
447
+ {
448
+ onResult: async (result) => {
449
+ const attachments = result.rows?._array.map(row => ({
450
+ id: row.photo_id,
451
+ fileExtension: 'jpg',
452
+ metaData: row.metadata
453
+ })) ?? [];
454
+ await onUpdate(attachments);
455
+ }
456
+ }
457
+ );
458
+ }
459
+ ```
460
+
461
+ ---
462
+
463
+ ### AttachmentTable
464
+
465
+ PowerSync schema table for storing attachment metadata.
466
+
467
+ #### Constructor
468
+
469
+ ```typescript
470
+ new AttachmentTable(options?: AttachmentTableOptions)
471
+ ```
472
+
473
+ **Options:**
474
+
475
+ Extends PowerSync `TableV2Options` (excluding `name` and `columns`).
476
+
477
+ | Parameter | Type | Description |
478
+ |-----------|------|-------------|
479
+ | `viewName` | `string` | View name for the table. Default: `'attachments'` |
480
+ | `localOnly` | `boolean` | Whether table is local-only. Default: `true` |
481
+ | `insertOnly` | `boolean` | Whether table is insert-only. Default: `false` |
482
+
483
+ #### Default Columns
484
+
485
+ | Column | Type | Description |
486
+ |--------|------|-------------|
487
+ | `id` | `TEXT` | Attachment ID (primary key) |
488
+ | `filename` | `TEXT` | Filename with extension |
489
+ | `local_uri` | `TEXT` | Local file path or URI |
490
+ | `timestamp` | `INTEGER` | Last update timestamp |
491
+ | `size` | `INTEGER` | File size in bytes |
492
+ | `media_type` | `TEXT` | MIME type |
493
+ | `state` | `INTEGER` | Sync state (see `AttachmentState`) |
494
+ | `has_synced` | `INTEGER` | Whether file has synced (0 or 1) |
495
+ | `meta_data` | `TEXT` | Optional metadata JSON string |
496
+
497
+ ---
498
+
499
+ ### AttachmentRecord
500
+
501
+ Interface representing an attachment record.
502
+
503
+ ```typescript
504
+ interface AttachmentRecord {
505
+ id: string;
506
+ filename: string;
507
+ localUri?: string;
508
+ size?: number;
509
+ mediaType?: string;
510
+ timestamp?: number;
511
+ metaData?: string;
512
+ hasSynced?: boolean;
513
+ state: AttachmentState;
514
+ }
515
+ ```
516
+
517
+ ---
518
+
519
+ ### AttachmentState
520
+
521
+ Enum representing attachment synchronization states.
522
+
523
+ ```typescript
524
+ enum AttachmentState {
525
+ QUEUED_UPLOAD = 0, // Queued for upload
526
+ QUEUED_DOWNLOAD = 1, // Queued for download
527
+ QUEUED_DELETE = 2, // Queued for deletion
528
+ SYNCED = 3, // Successfully synced
529
+ ARCHIVED = 4 // No longer referenced (orphaned)
530
+ }
531
+ ```
532
+
533
+ ---
534
+
535
+ ### LocalStorageAdapter
536
+
537
+ Interface for local file storage operations.
538
+
539
+ ```typescript
540
+ interface LocalStorageAdapter {
541
+ initialize(): Promise<void>;
542
+ clear(): Promise<void>;
543
+ getLocalUri(filename: string): string;
544
+ saveFile(filePath: string, data: ArrayBuffer | string): Promise<number>;
545
+ readFile(filePath: string): Promise<ArrayBuffer>;
546
+ deleteFile(filePath: string): Promise<void>;
547
+ fileExists(filePath: string): Promise<boolean>;
548
+ makeDir(path: string): Promise<void>;
549
+ rmDir(path: string): Promise<void>;
550
+ }
551
+ ```
552
+
553
+ ---
554
+
555
+ ### RemoteStorageAdapter
556
+
557
+ Interface for remote storage operations.
558
+
559
+ ```typescript
560
+ interface RemoteStorageAdapter {
561
+ uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord): Promise<void>;
562
+ downloadFile(attachment: AttachmentRecord): Promise<ArrayBuffer>;
563
+ deleteFile(attachment: AttachmentRecord): Promise<void>;
564
+ }
565
+ ```
566
+
567
+ ---
568
+
569
+ ### NodeFileSystemAdapter
570
+
571
+ Local storage adapter for Node.js and Electron.
572
+
573
+ **Constructor:**
574
+
575
+ ```typescript
576
+ new NodeFileSystemAdapter(storageDirectory?: string)
577
+ ```
578
+
579
+ - `storageDirectory` (optional): Directory path for storing files. Default: `'./user_data'`
580
+
581
+ ---
582
+
583
+ ### IndexDBFileSystemStorageAdapter
584
+
585
+ Local storage adapter for web browsers using IndexedDB.
586
+
587
+ **Constructor:**
588
+
589
+ ```typescript
590
+ new IndexDBFileSystemStorageAdapter(databaseName?: string)
591
+ ```
592
+
593
+ - `databaseName` (optional): IndexedDB database name. Default: `'PowerSyncFiles'`
594
+
595
+ ## Error Handling
596
+
597
+ The `AttachmentErrorHandler` interface allows you to customize error handling for sync operations.
598
+
599
+ ### Interface
600
+
601
+ ```typescript
602
+ interface AttachmentErrorHandler {
603
+ onDownloadError(attachment: AttachmentRecord, error: Error): Promise<boolean>;
604
+ onUploadError(attachment: AttachmentRecord, error: Error): Promise<boolean>;
605
+ onDeleteError(attachment: AttachmentRecord, error: Error): Promise<boolean>;
606
+ }
607
+ ```
608
+
609
+ Each method returns:
610
+ - `true` to retry the operation
611
+ - `false` to archive the attachment and skip retrying
612
+
613
+ ### Example
614
+
615
+ ```typescript
616
+ const errorHandler: AttachmentErrorHandler = {
617
+ async onDownloadError(attachment, error) {
618
+ console.error(`Download failed for ${attachment.filename}:`, error);
619
+
620
+ // Retry on network errors, archive on 404s
621
+ if (error.message.includes('404') || error.message.includes('Not Found')) {
622
+ console.log('File not found, archiving attachment');
623
+ return false; // Archive
624
+ }
625
+
626
+ console.log('Will retry download on next sync');
627
+ return true; // Retry
628
+ },
629
+
630
+ async onUploadError(attachment, error) {
631
+ console.error(`Upload failed for ${attachment.filename}:`, error);
632
+
633
+ // Always retry uploads
634
+ return true;
635
+ },
636
+
637
+ async onDeleteError(attachment, error) {
638
+ console.error(`Delete failed for ${attachment.filename}:`, error);
639
+
640
+ // Retry deletes, but archive after too many attempts
641
+ const attempts = attachment.metaData ?
642
+ JSON.parse(attachment.metaData).deleteAttempts || 0 : 0;
643
+
644
+ return attempts < 3; // Retry up to 3 times
645
+ }
646
+ };
647
+
648
+ const queue = new AttachmentQueue({
649
+ // ... other options
650
+ errorHandler
651
+ });
652
+ ```
653
+
654
+ ## Advanced Usage
655
+
656
+ ### Verification and Recovery
657
+
658
+ The `verifyAttachments()` method checks attachment integrity and repairs issues:
659
+
660
+ ```typescript
661
+ // Manually verify all attachments
662
+ await queue.verifyAttachments();
663
+ ```
664
+
665
+ This is useful if:
666
+ - Local files may have been manually deleted
667
+ - Storage paths changed
668
+ - You suspect data inconsistencies
669
+
670
+ Verification is automatically run when `startSync()` is called.
671
+
672
+ ### Custom Sync Intervals
673
+
674
+ Adjust sync frequency based on your needs:
675
+
676
+ ```typescript
677
+ const queue = new AttachmentQueue({
678
+ // ... other options
679
+ syncIntervalMs: 60000, // Sync every 60 seconds instead of 30
680
+ });
681
+ ```
682
+
683
+ Set to `0` to disable periodic syncing (manual `syncStorage()` calls only).
684
+
685
+ ### Archive and Cache Management
686
+
687
+ Control how many archived attachments are kept before cleanup:
688
+
689
+ ```typescript
690
+ const queue = new AttachmentQueue({
691
+ // ... other options
692
+ archivedCacheLimit: 200, // Keep up to 200 archived attachments
693
+ });
694
+ ```
695
+
696
+ Archived attachments are those no longer referenced in your data model but not yet deleted. This allows for:
697
+ - Quick restoration if references are added back
698
+ - Caching of recently used files
699
+ - Gradual cleanup to avoid storage bloat
700
+
701
+ When the limit is reached, the oldest archived attachments are permanently deleted.
702
+
703
+ ## Examples
704
+
705
+ See the following demo applications in this repository:
706
+
707
+ - **[react-native-supabase-todolist](../../../../demos/react-native-supabase-todolist)** - React Native mobile app with attachment support
708
+ - **[react-native-web-supabase-todolist](../../../../demos/react-native-web-supabase-todolist)** - React Native web app with Supabase Storage integration
709
+
710
+ Each demo shows a complete implementation including:
711
+ - Schema setup
712
+ - Storage adapter configuration
713
+ - File upload/download UI
714
+ - Error handling
715
+
716
+ ## License
717
+
718
+ Apache 2.0