Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions demos/react-native-supabase-todolist/.env
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Replace the credentials below with your Supabase, PowerSync and Expo project details.
EXPO_PUBLIC_SUPABASE_URL=https://foo.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=foo
EXPO_PUBLIC_ATTACHMENT_STORAGE_OPTION=supabase # Change this to s3 to use AWS S3 storage for attachments
EXPO_PUBLIC_SUPABASE_BUCKET= # Optional. Only required when syncing attachments and using Supabase Storage. See packages/powersync-attachments.
EXPO_PUBLIC_POWERSYNC_URL=https://foo.powersync.journeyapps.com
EXPO_PUBLIC_EAS_PROJECT_ID=foo # Optional. Only required when using EAS.
EXPO_PUBLIC_AWS_S3_REGION=region
EXPO_PUBLIC_AWS_S3_BUCKET_NAME=bucket_name
EXPO_PUBLIC_AWS_S3_ACCESS_KEY_ID=***
EXPO_PUBLIC_AWS_S3_ACCESS_SECRET_ACCESS_KEY=***
39 changes: 38 additions & 1 deletion demos/react-native-supabase-todolist/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

## Overview

Demo app demonstrating use of the [PowerSync SDK for React Native](https://www.npmjs.com/package/@powersync/react-native) together with Supabase.
Demo app demonstrating the use of the [PowerSync SDK for React Native](https://www.npmjs.com/package/@powersync/react-native) together with Supabase for authentication and two attachment storage options:
- Supabase (*default*), or
- [Amazon S3](https://docs.aws.amazon.com/s3/)

A step-by-step guide on Supabase<>PowerSync integration is available [here](https://docs.powersync.com/integration-guides/supabase).
Follow all the steps until, but not including, [Test Everything (Using Our Demo App)](https://docs.powersync.com/integration-guides/supabase-+-powersync#test-everything-using-our-demo-app).
Expand Down Expand Up @@ -30,6 +32,41 @@ cp .env .env.local

And then edit `.env.local` to insert your credentials for Supabase.

## Attachment storage options

To configure attachment storage, you can choose between Supabase and AWS S3.
In the `.env.local` file, set `EXPO_PUBLIC_ATTACHMENT_STORAGE_OPTION` to either `supabase` or `s3` to configure the attachment storage option.

### AWS S3 Setup

> **_NOTE:_** This guide assumes that you have an AWS account.

To enable attachment storage using AWS S3, set up an S3 bucket by following these steps:
#### Create an S3 Bucket:

- Go to the [S3 Console](https://s3.console.aws.amazon.com/s3) and click `Create bucket`.
- Enter a unique bucket name and select your preferred region.
- Under Object Ownership, set ACLs disabled and ensure the bucket is private.
- Enable Bucket Versioning if you need to track changes to files (optional).

#### Configure Permissions:

Go to the Permissions tab and set up the following:
- A **bucket policy** for access control:
- Click Bucket policy and enter a policy allowing the necessary actions
(e.g., s3:PutObject, s3:GetObject) for the specific users or roles.
- _**(Optional)**_ Configure CORS (Cross-Origin Resource Sharing) if your app requires it

#### Create IAM User for Access

- Go to the [IAM Console](https://console.aws.amazon.com/iam) and create a new user with programmatic access.
- Attach an AmazonS3FullAccess policy to this user, or create a custom policy with specific permissions for the bucket.
- Save the Access Key ID and Secret Access Key.

In your `.env.local` file, add your AWS credentials and S3 bucket name.

## Run app

Run on iOS

```sh
Expand Down
40 changes: 34 additions & 6 deletions demos/react-native-supabase-todolist/library/powersync/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,32 @@ import '@azure/core-asynciterator-polyfill';

import { PowerSyncDatabase } from '@powersync/react-native';
import React from 'react';
import { SupabaseStorageAdapter } from '../storage/SupabaseStorageAdapter';

import { type AttachmentRecord } from '@powersync/attachments';
import S3 from 'aws-sdk/clients/s3';
import { type AttachmentRecord, StorageAdapter } from '@powersync/attachments';
import Logger from 'js-logger';
import { KVStorage } from '../storage/KVStorage';
import { AppConfig } from '../supabase/AppConfig';
import { SupabaseConnector } from '../supabase/SupabaseConnector';
import { AppSchema } from './AppSchema';
import { PhotoAttachmentQueue } from './PhotoAttachmentQueue';
import { createClient } from '@supabase/supabase-js';
import { SupabaseStorageAdapter } from '../storage/SupabaseStorageAdapter';
import { AWSConfig } from '../storage/AWSConfig';
import { AWSStorageAdapter } from '../storage/AWSStorageAdapter';

Logger.useDefaults();

export class System {
kvStorage: KVStorage;
storage: SupabaseStorageAdapter;
storage: StorageAdapter;
supabaseConnector: SupabaseConnector;
powersync: PowerSyncDatabase;
attachmentQueue: PhotoAttachmentQueue | undefined = undefined;

constructor() {
this.kvStorage = new KVStorage();
this.supabaseConnector = new SupabaseConnector(this);
this.storage = this.supabaseConnector.storage;
this.storage = getStorageAdapter(this);
this.powersync = new PowerSyncDatabase({
schema: AppSchema,
database: {
Expand All @@ -44,7 +47,7 @@ export class System {
* this.powersync = new PowerSyncDatabase({ database: factory, schema: AppSchema });
*/

if (AppConfig.supabaseBucket) {
if (AppConfig.supabaseBucket || AppConfig.s3bucketName) {
this.attachmentQueue = new PhotoAttachmentQueue({
powersync: this.powersync,
storage: this.storage,
Expand All @@ -71,6 +74,31 @@ export class System {
}
}

function getStorageAdapter(system: System): StorageAdapter {
const storageProvider = AppConfig.storageOption;

if (storageProvider === 'supabase') {
const client = createClient(AppConfig.supabaseUrl, AppConfig.supabaseAnonKey, {
auth: {
persistSession: true,
storage: system.kvStorage
}
});
return new SupabaseStorageAdapter({ client: client });
} else if (storageProvider === 's3') {
const s3Client = new S3({
region: AWSConfig.region,
credentials: {
accessKeyId: AWSConfig.accessKeyId,
secretAccessKey: AWSConfig.secretAccessKey
}
});
return new AWSStorageAdapter({ client: s3Client });
} else {
throw new Error('Invalid storage provider specified in STORAGE_PROVIDER');
}
}

export const system = new System();

export const SystemContext = React.createContext(system);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export const AWSConfig = {
region: process.env.EXPO_PUBLIC_AWS_S3_REGION,
accessKeyId: process.env.EXPO_PUBLIC_AWS_S3_ACCESS_KEY_ID || '',
secretAccessKey: process.env.EXPO_PUBLIC_AWS_S3_ACCESS_SECRET_ACCESS_KEY || '',
bucketName: process.env.EXPO_PUBLIC_AWS_S3_BUCKET_NAME || ''
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import * as FileSystem from 'expo-file-system';
import { AWSConfig } from './AWSConfig';
import S3 from 'aws-sdk/clients/s3';
import { BaseStorageAdapter } from './BaseStorageAdapter';

export interface S3StorageAdapterOptions {
client: S3;
}

export class AWSStorageAdapter extends BaseStorageAdapter {
constructor(private options: S3StorageAdapterOptions) {
super();
}

async uploadFile(
filename: string,
data: ArrayBuffer,
options?: {
mediaType?: string;
}
): Promise<void> {
if (!AWSConfig.bucketName) {
throw new Error('AWS S3 bucket not configured in AppConfig.ts');
}

try {
const body = Uint8Array.from(new Uint8Array(data));
const params = {
Bucket: AWSConfig.bucketName,
Key: filename,
Body: body,
ContentType: options?.mediaType
};

await this.options.client.upload(params).promise();
console.log(`File uploaded successfully to ${AWSConfig.bucketName}/${filename}`);
} catch (error) {
console.error('Error uploading file:', error);
throw error;
}
}

async downloadFile(filePath: string): Promise<Blob> {
const s3 = new S3({
region: AWSConfig.region,
accessKeyId: AWSConfig.accessKeyId,
secretAccessKey: AWSConfig.secretAccessKey
});

const params = {
Bucket: AWSConfig.bucketName,
Key: filePath
};

try {
const obj = await s3.getObject(params).promise();
if (obj.Body) {
const data = await new Response(obj.Body as ReadableStream).arrayBuffer();
return new Blob([data]);
} else {
throw new Error('Object body is undefined. Could not download file.');
}
} catch (error) {
console.error('Error downloading file:', error);
throw error;
}
}

async deleteFile(uri: string, options?: { filename?: string }): Promise<void> {
if (await this.fileExists(uri)) {
await FileSystem.deleteAsync(uri);
}

const { filename } = options ?? {};
if (!filename) {
return;
}

if (!AWSConfig.bucketName) {
throw new Error('Supabase bucket not configured in AppConfig.ts');
}

try {
const params = {
Bucket: AWSConfig.bucketName,
Key: filename
};
await this.options.client.deleteObject(params).promise();
console.log(`${filename} deleted successfully from ${AWSConfig.bucketName}.`);
} catch (error) {
console.error(`Error deleting ${filename} from ${AWSConfig.bucketName}:`, error);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { StorageAdapter } from '@powersync/attachments';
import * as FileSystem from 'expo-file-system';
import { decode as decodeBase64 } from 'base64-arraybuffer';

export abstract class BaseStorageAdapter implements StorageAdapter {
async readFile(
fileURI: string,
options?: { encoding?: FileSystem.EncodingType; mediaType?: string }
): Promise<ArrayBuffer> {
const { encoding = FileSystem.EncodingType.UTF8 } = options ?? {};
const { exists } = await FileSystem.getInfoAsync(fileURI);
if (!exists) {
throw new Error(`File does not exist: ${fileURI}`);
}
const fileContent = await FileSystem.readAsStringAsync(fileURI, options);
if (encoding === FileSystem.EncodingType.Base64) {
return this.base64ToArrayBuffer(fileContent);
}
return this.stringToArrayBuffer(fileContent);
}

async writeFile(
fileURI: string,
base64Data: string,
options?: {
encoding?: FileSystem.EncodingType;
}
): Promise<void> {
const { encoding = FileSystem.EncodingType.UTF8 } = options ?? {};
await FileSystem.writeAsStringAsync(fileURI, base64Data, { encoding });
}

async fileExists(fileURI: string): Promise<boolean> {
const { exists } = await FileSystem.getInfoAsync(fileURI);
return exists;
}

async makeDir(uri: string): Promise<void> {
const { exists } = await FileSystem.getInfoAsync(uri);
if (!exists) {
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
}
}

async copyFile(sourceUri: string, targetUri: string): Promise<void> {
await FileSystem.copyAsync({ from: sourceUri, to: targetUri });
}

getUserStorageDirectory(): string {
return FileSystem.documentDirectory!;
}

async stringToArrayBuffer(str: string): Promise<ArrayBuffer> {
const encoder = new TextEncoder();
return encoder.encode(str).buffer;
}

/**
* Converts a base64 string to an ArrayBuffer
*/
async base64ToArrayBuffer(base64: string): Promise<ArrayBuffer> {
return decodeBase64(base64);
}

abstract uploadFile(filePath: string, data: ArrayBuffer, options?: { mediaType?: string }): Promise<void>;
abstract downloadFile(filePath: string): Promise<Blob>;
abstract deleteFile(uri: string, options?: { filename?: string }): Promise<void>;
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { decode as decodeBase64 } from 'base64-arraybuffer';
import * as FileSystem from 'expo-file-system';
import { AppConfig } from '../supabase/AppConfig';
import { StorageAdapter } from '@powersync/attachments';
import { BaseStorageAdapter } from './BaseStorageAdapter';

export interface SupabaseStorageAdapterOptions {
client: SupabaseClient;
}

export class SupabaseStorageAdapter implements StorageAdapter {
constructor(private options: SupabaseStorageAdapterOptions) {}
export class SupabaseStorageAdapter extends BaseStorageAdapter {
constructor(private options: SupabaseStorageAdapterOptions) {
super();
}

async uploadFile(
filename: string,
Expand Down Expand Up @@ -55,21 +56,6 @@ export class SupabaseStorageAdapter implements StorageAdapter {
const { encoding = FileSystem.EncodingType.UTF8 } = options ?? {};
await FileSystem.writeAsStringAsync(fileURI, base64Data, { encoding });
}
async readFile(
fileURI: string,
options?: { encoding?: FileSystem.EncodingType; mediaType?: string }
): Promise<ArrayBuffer> {
const { encoding = FileSystem.EncodingType.UTF8 } = options ?? {};
const { exists } = await FileSystem.getInfoAsync(fileURI);
if (!exists) {
throw new Error(`File does not exist: ${fileURI}`);
}
const fileContent = await FileSystem.readAsStringAsync(fileURI, options);
if (encoding === FileSystem.EncodingType.Base64) {
return this.base64ToArrayBuffer(fileContent);
}
return this.stringToArrayBuffer(fileContent);
}

async deleteFile(uri: string, options?: { filename?: string }): Promise<void> {
if (await this.fileExists(uri)) {
Expand All @@ -93,36 +79,4 @@ export class SupabaseStorageAdapter implements StorageAdapter {

console.debug('Deleted file from storage', data);
}

async fileExists(fileURI: string): Promise<boolean> {
const { exists } = await FileSystem.getInfoAsync(fileURI);
return exists;
}

async makeDir(uri: string): Promise<void> {
const { exists } = await FileSystem.getInfoAsync(uri);
if (!exists) {
await FileSystem.makeDirectoryAsync(uri, { intermediates: true });
}
}

async copyFile(sourceUri: string, targetUri: string): Promise<void> {
await FileSystem.copyAsync({ from: sourceUri, to: targetUri });
}

getUserStorageDirectory(): string {
return FileSystem.documentDirectory!;
}

async stringToArrayBuffer(str: string): Promise<ArrayBuffer> {
const encoder = new TextEncoder();
return encoder.encode(str).buffer;
}

/**
* Converts a base64 string to an ArrayBuffer
*/
async base64ToArrayBuffer(base64: string): Promise<ArrayBuffer> {
return decodeBase64(base64);
}
}
Loading
Loading