Skip to content

Commit d5c726d

Browse files
authored
Merge pull request #735 from powersync-ja/attachment-package-refactor
Update attachment package
2 parents e1fed97 + ed32db1 commit d5c726d

55 files changed

Lines changed: 8321 additions & 3342 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/angry-planes-accept.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@powersync/react-native': minor
3+
'@powersync/common': minor
4+
'@powersync/node': minor
5+
'@powersync/web': minor
6+
'@powersync/attachments': patch
7+
---
8+
9+
Deprecated @powersync/attachments in favor of enhanced and consistent attachment functionality built into @powersync/common and platform-specific SDKs
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@powersync/attachments-storage-react-native': minor
3+
---
4+
5+
Added new @powersync/attachments-storage-react-native package providing LocalStorageAdapter implementations for React Native environments. Includes ExpoFileSystemStorageAdapter and ReactNativeFileSystemStorageAdapter for device-based attachment file storage.

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ _[PowerSync](https://www.powersync.com) is a sync engine for building local-firs
3636

3737
- [packages/attachments](./packages/attachments/README.md)
3838

39-
- Attachments helper package for React Native and JavaScript/TypeScript projects.
39+
- Attachments helper package for React Native and JavaScript/TypeScript projects (deprecated).
40+
41+
- [packages/attachments-storage-react-native](./packages/attachments-storage-react-native/README.md)
42+
43+
- React Native file system storage adapters for PowerSync attachments (alpha).
4044

4145
- [packages/kysely-driver](./packages/kysely-driver/README.md)
4246

@@ -51,7 +55,7 @@ _[PowerSync](https://www.powersync.com) is a sync engine for building local-firs
5155
- [OP-SQLite](https://github.com/OP-Engineering/op-sqlite) integration for React Native projects. Alternative to the default usage of [react-native-quick-sqlite](https://github.com/powersync-ja/react-native-quick-sqlite).
5256

5357
- [packages/common](./packages/common/README.md)
54-
- Shared package: TypeScript implementation of a PowerSync database connector and streaming sync bucket implementation.
58+
- Shared package: TypeScript implementation of a PowerSync database connector, streaming sync bucket implementation and attachment utilities.
5559

5660
## Demo Apps / Example Projects
5761

demos/react-native-supabase-todolist/app/views/todos/edit/[id].tsx

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { ATTACHMENT_TABLE, AttachmentRecord } from '@powersync/attachments';
2-
import { usePowerSync, useQuery } from '@powersync/react-native';
1+
import { usePowerSync, useQuery, ATTACHMENT_TABLE, attachmentFromSql, AttachmentRecord } from '@powersync/react-native';
32
import { CameraCapturedPicture } from 'expo-camera';
43
import _ from 'lodash';
54
import * as React from 'react';
@@ -12,21 +11,7 @@ import { TODO_TABLE, TodoRecord, LIST_TABLE } from '../../../../library/powersyn
1211
import { useSystem } from '../../../../library/powersync/system';
1312
import { TodoItemWidget } from '../../../../library/widgets/TodoItemWidget';
1413

15-
type TodoEntry = TodoRecord & Partial<Omit<AttachmentRecord, 'id'>> & { todo_id: string; attachment_id: string | null };
16-
17-
const toAttachmentRecord = _.memoize((entry: TodoEntry): AttachmentRecord | null => {
18-
return entry.attachment_id == null
19-
? null
20-
: {
21-
id: entry.attachment_id,
22-
filename: entry.filename!,
23-
state: entry.state!,
24-
timestamp: entry.timestamp,
25-
local_uri: entry.local_uri,
26-
media_type: entry.media_type,
27-
size: entry.size
28-
};
29-
});
14+
type TodoEntry = TodoRecord & { todo_id: string; attachment_id: string | null };
3015

3116
const TodoView: React.FC = () => {
3217
const system = useSystem();
@@ -61,10 +46,10 @@ const TodoView: React.FC = () => {
6146
if (completed) {
6247
const userID = await system.supabaseConnector.userId();
6348
updatedRecord.completed_at = new Date().toISOString();
64-
updatedRecord.completed_by = userID;
49+
updatedRecord.completed_by = userID!;
6550
} else {
66-
updatedRecord.completed_at = undefined;
67-
updatedRecord.completed_by = undefined;
51+
updatedRecord.completed_at = null;
52+
updatedRecord.completed_by = null;
6853
}
6954
await system.powersync.execute(
7055
`UPDATE ${TODO_TABLE}
@@ -77,9 +62,13 @@ const TodoView: React.FC = () => {
7762
};
7863

7964
const savePhoto = async (id: string, data: CameraCapturedPicture) => {
80-
if (system.attachmentQueue) {
65+
if (system.photoAttachmentQueue) {
8166
// We are sure the base64 is not null, as we are using the base64 option in the CameraWidget
82-
const { id: photoId } = await system.attachmentQueue.savePhoto(data.base64!);
67+
const { id: photoId } = await system.photoAttachmentQueue.saveFile({
68+
data: data.base64!,
69+
fileExtension: 'jpg',
70+
mediaType: 'image/jpeg'
71+
});
8372

8473
await system.powersync.execute(`UPDATE ${TODO_TABLE} SET photo_id = ? WHERE id = ?`, [photoId, id]);
8574
}
@@ -99,12 +88,16 @@ const TodoView: React.FC = () => {
9988
};
10089

10190
const deleteTodo = async (id: string, photoRecord?: AttachmentRecord) => {
102-
await system.powersync.writeTransaction(async (tx) => {
103-
if (system.attachmentQueue && photoRecord != null) {
104-
await system.attachmentQueue.delete(photoRecord, tx);
105-
}
106-
await tx.execute(`DELETE FROM ${TODO_TABLE} WHERE id = ?`, [id]);
107-
});
91+
if (system.photoAttachmentQueue && photoRecord != null) {
92+
await system.photoAttachmentQueue.deleteFile({
93+
id: photoRecord.id,
94+
updateHook: async (tx) => {
95+
await tx.execute(`DELETE FROM ${TODO_TABLE} WHERE id = ?`, [id]);
96+
}
97+
});
98+
} else {
99+
await system.powersync.execute(`DELETE FROM ${TODO_TABLE} WHERE id = ?`, [id]);
100+
}
108101
};
109102

110103
if (isLoading) {
@@ -157,7 +150,7 @@ const TodoView: React.FC = () => {
157150
<ScrollView style={{ maxHeight: '90%' }}>
158151
{todos.map((r) => {
159152
const record = { ...r, id: r.todo_id };
160-
const photoRecord = toAttachmentRecord(r);
153+
const photoRecord = attachmentFromSql(r);
161154
return (
162155
<TodoItemWidget
163156
key={r.todo_id}

demos/react-native-supabase-todolist/library/powersync/AppSchema.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { AttachmentTable } from '@powersync/attachments';
21
import { column, Schema, Table } from '@powersync/react-native';
2+
import { AttachmentTable } from '@powersync/common';
33

44
export const LIST_TABLE = 'lists';
55
export const TODO_TABLE = 'todos';
@@ -27,9 +27,7 @@ const lists = new Table({
2727
export const AppSchema = new Schema({
2828
todos,
2929
lists,
30-
attachments: new AttachmentTable({
31-
name: 'attachments',
32-
}),
30+
attachments: new AttachmentTable(),
3331
});
3432

3533
export type Database = (typeof AppSchema)['types'];

demos/react-native-supabase-todolist/library/powersync/PhotoAttachmentQueue.ts

Lines changed: 0 additions & 50 deletions
This file was deleted.

demos/react-native-supabase-todolist/library/powersync/system.ts

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,41 @@
11
import '@azure/core-asynciterator-polyfill';
22

3-
import { createBaseLogger, LogLevel, PowerSyncDatabase, SyncClientImplementation } from '@powersync/react-native';
3+
import {
4+
createBaseLogger,
5+
LogLevel,
6+
PowerSyncDatabase,
7+
SyncClientImplementation,
8+
AttachmentQueue,
9+
type AttachmentRecord,
10+
type WatchedAttachmentItem,
11+
} from '@powersync/react-native';
12+
import { ReactNativeFileSystemStorageAdapter } from '@powersync/attachments-storage-react-native';
413
import React from 'react';
5-
import { SupabaseStorageAdapter } from '../storage/SupabaseStorageAdapter';
6-
7-
import { type AttachmentRecord } from '@powersync/attachments';
814
import { configureFts } from '../fts/fts_setup';
915
import { KVStorage } from '../storage/KVStorage';
16+
import { SupabaseRemoteStorageAdapter } from '../storage/SupabaseRemoteStorageAdapter';
1017
import { AppConfig } from '../supabase/AppConfig';
1118
import { SupabaseConnector } from '../supabase/SupabaseConnector';
12-
import { AppSchema } from './AppSchema';
13-
import { PhotoAttachmentQueue } from './PhotoAttachmentQueue';
19+
import { AppSchema, TODO_TABLE } from './AppSchema';
1420

1521
const logger = createBaseLogger();
1622
logger.useDefaults();
1723
logger.setLevel(LogLevel.DEBUG);
1824

1925
export class System {
2026
kvStorage: KVStorage;
21-
storage: SupabaseStorageAdapter;
2227
supabaseConnector: SupabaseConnector;
2328
powersync: PowerSyncDatabase;
24-
attachmentQueue: PhotoAttachmentQueue | undefined = undefined;
29+
photoAttachmentQueue: AttachmentQueue | undefined = undefined;
2530

2631
constructor() {
2732
this.kvStorage = new KVStorage();
28-
this.supabaseConnector = new SupabaseConnector(this);
29-
this.storage = this.supabaseConnector.storage;
33+
this.supabaseConnector = new SupabaseConnector({
34+
kvStorage: this.kvStorage,
35+
supabaseUrl: AppConfig.supabaseUrl,
36+
supabaseAnonKey: AppConfig.supabaseAnonKey
37+
});
38+
3039
this.powersync = new PowerSyncDatabase({
3140
schema: AppSchema,
3241
database: {
@@ -50,18 +59,48 @@ export class System {
5059
*/
5160

5261
if (AppConfig.supabaseBucket) {
53-
this.attachmentQueue = new PhotoAttachmentQueue({
54-
powersync: this.powersync,
55-
storage: this.storage,
56-
// Use this to handle download errors where you can use the attachment
57-
// and/or the exception to decide if you want to retry the download
58-
onDownloadError: async (attachment: AttachmentRecord, exception: any) => {
59-
if (exception.toString() === 'StorageApiError: Object not found') {
60-
return { retry: false };
61-
}
62+
const localStorage = new ReactNativeFileSystemStorageAdapter();
63+
const remoteStorage = new SupabaseRemoteStorageAdapter({
64+
client: this.supabaseConnector.client,
65+
bucket: AppConfig.supabaseBucket
66+
});
67+
68+
this.photoAttachmentQueue = new AttachmentQueue({
69+
db: this.powersync,
70+
localStorage,
71+
remoteStorage,
72+
watchAttachments: async (onUpdate, signal) => {
73+
const watcher = this.powersync.watch(
74+
`SELECT photo_id as id FROM ${TODO_TABLE} WHERE photo_id IS NOT NULL`,
75+
[],
76+
{
77+
signal
78+
}
79+
);
6280

63-
return { retry: true };
64-
}
81+
for await (const result of watcher) {
82+
const attachments: WatchedAttachmentItem[] = (result.rows?._array ?? []).map((row: any) => ({
83+
id: row.id,
84+
fileExtension: 'jpg'
85+
}));
86+
await onUpdate(attachments);
87+
}
88+
},
89+
errorHandler: {
90+
onDownloadError: async (attachment: AttachmentRecord, error: Error) => {
91+
if (error.toString() === 'StorageApiError: Object not found') {
92+
return false; // Don't retry
93+
}
94+
return true; // Retry
95+
},
96+
onUploadError: async (attachment: AttachmentRecord, error: Error) => {
97+
return true; // Retry uploads by default
98+
},
99+
onDeleteError: async (attachment: AttachmentRecord, error: Error) => {
100+
return true; // Retry deletes by default
101+
}
102+
},
103+
logger
65104
});
66105
}
67106
}
@@ -70,8 +109,8 @@ export class System {
70109
await this.powersync.init();
71110
await this.powersync.connect(this.supabaseConnector, { clientImplementation: SyncClientImplementation.RUST });
72111

73-
if (this.attachmentQueue) {
74-
await this.attachmentQueue.init();
112+
if (this.photoAttachmentQueue) {
113+
await this.photoAttachmentQueue.startSync();
75114
}
76115

77116
// Demo using SQLite Full-Text Search with PowerSync.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { SupabaseClient } from '@supabase/supabase-js';
2+
import { AttachmentRecord, RemoteStorageAdapter } from '@powersync/react-native';
3+
4+
export interface SupabaseRemoteStorageAdapterOptions {
5+
client: SupabaseClient;
6+
bucket: string;
7+
}
8+
9+
/**
10+
* SupabaseRemoteStorageAdapter implements RemoteStorageAdapter for Supabase Storage.
11+
* Handles upload, download, and deletion of files from Supabase Storage buckets.
12+
*/
13+
export class SupabaseRemoteStorageAdapter implements RemoteStorageAdapter {
14+
constructor(private options: SupabaseRemoteStorageAdapterOptions) {}
15+
16+
async uploadFile(fileData: ArrayBuffer, attachment: AttachmentRecord): Promise<void> {
17+
const mediaType = attachment.mediaType ?? 'application/octet-stream';
18+
19+
const { error } = await this.options.client.storage
20+
.from(this.options.bucket)
21+
.upload(attachment.filename, fileData, { contentType: mediaType });
22+
23+
if (error) {
24+
throw error;
25+
}
26+
}
27+
28+
async downloadFile(attachment: AttachmentRecord): Promise<ArrayBuffer> {
29+
const { data, error } = await this.options.client.storage.from(this.options.bucket).download(attachment.filename);
30+
31+
if (error) {
32+
throw error;
33+
}
34+
35+
return new Promise((resolve, reject) => {
36+
const reader = new FileReader();
37+
reader.onloadend = () => {
38+
resolve(reader.result as ArrayBuffer);
39+
};
40+
reader.onerror = reject;
41+
reader.readAsArrayBuffer(data);
42+
});
43+
}
44+
45+
async deleteFile(attachment: AttachmentRecord): Promise<void> {
46+
const { error } = await this.options.client.storage.from(this.options.bucket).remove([attachment.filename]);
47+
48+
if (error) {
49+
console.debug('Failed to delete file from Supabase Storage', error);
50+
throw error;
51+
}
52+
}
53+
}

0 commit comments

Comments
 (0)