diff --git a/images/tutorials/pdf-attachment/Supabase-sql-editor.png b/images/tutorials/pdf-attachment/Supabase-sql-editor.png new file mode 100644 index 00000000..29af823e Binary files /dev/null and b/images/tutorials/pdf-attachment/Supabase-sql-editor.png differ diff --git a/images/tutorials/pdf-attachment/Supabase-table-editor.png b/images/tutorials/pdf-attachment/Supabase-table-editor.png new file mode 100644 index 00000000..a4943577 Binary files /dev/null and b/images/tutorials/pdf-attachment/Supabase-table-editor.png differ diff --git a/mint.json b/mint.json index 1b8ccc1b..bfef208e 100644 --- a/mint.json +++ b/mint.json @@ -377,10 +377,11 @@ "pages": [ "tutorials/overview", { - "group": "Attachment Storage", + "group": "Attachments / Files", "pages": [ - "tutorials/client/attachment-storage/overview", - "tutorials/client/attachment-storage/aws-s3-storage-adapter" + "tutorials/client/attachments-and-files/overview", + "tutorials/client/attachments-and-files/aws-s3-storage-adapter", + "tutorials/client/attachments-and-files/pdf-attachment" ] }, { diff --git a/tutorials/client/attachment-storage/overview.mdx b/tutorials/client/attachment-storage/overview.mdx deleted file mode 100644 index 2503a7cc..00000000 --- a/tutorials/client/attachment-storage/overview.mdx +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: "Overview" -description: "A collection of tutorials exploring storage strategies." ---- - - - - diff --git a/tutorials/client/attachment-storage/aws-s3-storage-adapter.mdx b/tutorials/client/attachments-and-files/aws-s3-storage-adapter.mdx similarity index 99% rename from tutorials/client/attachment-storage/aws-s3-storage-adapter.mdx rename to tutorials/client/attachments-and-files/aws-s3-storage-adapter.mdx index 5531186e..30597491 100644 --- a/tutorials/client/attachment-storage/aws-s3-storage-adapter.mdx +++ b/tutorials/client/attachments-and-files/aws-s3-storage-adapter.mdx @@ -1,7 +1,7 @@ --- title: "Use AWS S3 for attachment storage" description: "In this tutorial, we will show you how to replace Supabase Storage with AWS S3 for handling attachments in the [React Native To-Do List example app](https://github.com/powersync-ja/powersync-js/tree/main/demos/react-native-supabase-todolist)." -sidebarTitle: "AWS S3" +sidebarTitle: "AWS S3 Storage" --- diff --git a/tutorials/client/attachments-and-files/overview.mdx b/tutorials/client/attachments-and-files/overview.mdx new file mode 100644 index 00000000..b97bfc23 --- /dev/null +++ b/tutorials/client/attachments-and-files/overview.mdx @@ -0,0 +1,9 @@ +--- +title: "Overview" +description: "A collection of tutorials exploring storage strategies." +--- + + + + + diff --git a/tutorials/client/attachments-and-files/pdf-attachment.mdx b/tutorials/client/attachments-and-files/pdf-attachment.mdx new file mode 100644 index 00000000..9cfaa9a8 --- /dev/null +++ b/tutorials/client/attachments-and-files/pdf-attachment.mdx @@ -0,0 +1,314 @@ +--- +title: "PDF attachments" +description: "In this tutorial we will show you how to modify the [PhotoAttachmentQueue](https://github.com/powersync-ja/powersync-js/blob/main/demos/react-native-supabase-todolist/library/powersync/PhotoAttachmentQueue.ts) for PDF attachments." +keywords: ["pdf", "attachment", "storage"] +--- + + +# Introduction + +The current version of the [To-Do List demo app](https://github.com/powersync-ja/powersync-js/tree/main/demos/react-native-supabase-todolist) implements a `PhotoAttachmentQueue` class which +enables photo attachments (specifially a `jpeg`) to be synced. This tutorial will guide you on the changes needed to support PDF attachments. + +An overview of the required changes are: +1. Update the app schema by adding a `pdf_id` column to the todos table to link a pdf to a to-do item. +2. Add a `PdfAttachmentQueue` class +3. Initialize the `PdfAttachmentQueue` class + + + The following pre-requisites are required to complete this tutorial: + - Clone the [To-Do List demo app](https://github.com/powersync-ja/powersync-js/tree/main/demos/react-native-supabase-todolist) repo + - Follow the instructions in the [README](https://github.com/powersync-ja/powersync-js/blob/main/demos/react-native-supabase-todolist/README.md) and ensure that the app runs locally + - A running PowerSync Service and Supabase (can be self-hosted) + - [Storage configuration in Supabase](/integration-guides/supabase-+-powersync/handling-attachments#configure-storage-in-supabase) + + +# Steps + + + + You can add a _nullable text_ `pdf_id` column to the to-do table via either the `Table Editor` or `SQL Editor` in Supabase. + + ## Table Editor + + + ## SQL Editor + - Navigate to the `SQL Editor` tab: + + - Execute the following SQL: + ```sql + ALTER TABLE public.todos ADD COLUMN pdf_id text NULL; + ``` + + You can now update the [AppSchema](https://github.com/powersync-ja/powersync-js/blob/main/demos/react-native-supabase-todolist/library/powersync/AppSchema.ts) to include the newly created column. + ```typescript AppSchema.ts + export interface TodoRecord { + // existing code + pdf_id?: string; + } + + export const AppSchema = new Schema([ + new Table({ + name: 'todos', + columns: [ + // existing columns + new Column({ name: 'pdf_id', type: ColumnType.TEXT }) + ] + }) + // existing code + ]); + ``` + + + + + The `PdfAttachmentQueue` class below updates the existing [PhotoAttachmentQueue](https://github.com/powersync-ja/powersync-js/blob/main/demos/react-native-supabase-todolist/library/powersync/PhotoAttachmentQueue.ts) + found in the demo app. The highlighted lines indicate which lines have been updated. For more information on attachments, see the [attachments package](https://github.com/powersync-ja/powersync-js/tree/main/packages/attachments). + + ```typescript {7, 10, 20, 26-27, 29, 31, 37-40, 45, 48} PdfAttachmentQueue.ts + import * as FileSystem from 'expo-file-system'; + import { randomUUID } from 'expo-crypto'; + import { AppConfig } from '../supabase/AppConfig'; + import { AbstractAttachmentQueue, AttachmentRecord, AttachmentState } from '@powersync/attachments'; + import { TODO_TABLE } from './AppSchema'; + + export class PdfAttachmentQueue extends AbstractAttachmentQueue { + async init() { + if (!AppConfig.supabaseBucket) { + console.debug('No Supabase bucket configured, skip setting up PdfAttachmentQueue watches'); + // Disable sync interval to prevent errors from trying to sync to a non-existent bucket + this.options.syncInterval = 0; + return; + } + + await super.init(); + } + + onAttachmentIdsChange(onUpdate: (ids: string[]) => void): void { + this.powersync.watch(`SELECT pdf_id as id FROM ${TODO_TABLE} WHERE pdf_id IS NOT NULL`, [], { + onResult: (result) => onUpdate(result.rows?._array.map((r) => r.id) ?? []) + }); + } + + async newAttachmentRecord(record?: Partial): Promise { + const pdfId = record?.id ?? randomUUID(); + const filename = record?.filename ?? `${pdfId}.pdf`; + return { + id: pdfId, + filename, + media_type: 'application/pdf', + state: AttachmentState.QUEUED_UPLOAD, + ...record + }; + } + + async saveAttachment(base64Data: string): Promise { + const attachment = await this.newAttachmentRecord(); + attachment.local_uri = this.getLocalFilePathSuffix(attachment.filename); + const localUri = this.getLocalUri(attachment.local_uri); + await this.storage.writeFile(localUri, base64Data, { encoding: FileSystem.EncodingType.Base64 }); + + const fileInfo = await FileSystem.getInfoAsync(localUri); + if (fileInfo.exists) { + attachment.size = fileInfo.size; + } + + return this.saveToQueue(photoAttachment); + } + } + ``` + + + + We start by importing the `PdfAttachmentQueue` and adding an `attachmentPdfQueue` class variable. + + ```typescript + // Additional imports + import { PdfAttachmentQueue } from './PdfAttachmentQueue'; + + export class System { + // Existing class variables + attachmentPdfQueue: PdfAttachmentQueue | undefined = undefined; + ... + } + ``` + The `attachmentPdfQueue` can then be initialized in the constructor, where a new instance of PdfAttachmentQueue is created and assigned to `attachmentPdfQueue` if the `supabaseBucket` is configured. + ```typescript + constructor() { + // init code + if (AppConfig.supabaseBucket) { + // init PhotoAttachmentQueue + this.attachmentPdfQueue = new PdfAttachmentQueue({ + powersync: this.powersync, + storage: this.storage, + // Use this to handle download errors where you can use the attachment + // and/or the exception to decide if you want to retry the download + onDownloadError: async (attachment: AttachmentRecord, exception: any) => { + if (exception.toString() === 'StorageApiError: Object not found') { + return { retry: false }; + } + + return { retry: true }; + } + }); + } + } + ``` + + We can then update the `init` method to include the initialization of the `attachmentPdfQueue`. + ```typescript + await init() { + // init powersync + if (this.attachmentPdfQueue) { + await this.attachmentPdfQueue.init(); + } + } + ``` + + + + The complete updated `system.ts` file can be found below with highlighted lines indicating the changes made above. + ```typescript system.ts {14, 24, 63-75, 86-88} + 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 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 { PdfAttachmentQueue } from './PdfAttachmentQueue'; + + Logger.useDefaults(); + + export class System { + kvStorage: KVStorage; + storage: SupabaseStorageAdapter; + supabaseConnector: SupabaseConnector; + powersync: PowerSyncDatabase; + attachmentQueue: PhotoAttachmentQueue | undefined = undefined; + attachmentPdfQueue: PdfAttachmentQueue | undefined = undefined; + + constructor() { + this.kvStorage = new KVStorage(); + this.supabaseConnector = new SupabaseConnector(this); + this.storage = this.supabaseConnector.storage; + this.powersync = new PowerSyncDatabase({ + schema: AppSchema, + database: { + dbFilename: 'sqlite.db' + } + }); + /** + * The snippet below uses OP-SQLite as the default database adapter. + * You will have to uninstall `@journeyapps/react-native-quick-sqlite` and + * install both `@powersync/op-sqlite` and `@op-engineering/op-sqlite` to use this. + * + * import { OPSqliteOpenFactory } from '@powersync/op-sqlite'; // Add this import + * + * const factory = new OPSqliteOpenFactory({ + * dbFilename: 'sqlite.db' + * }); + * this.powersync = new PowerSyncDatabase({ database: factory, schema: AppSchema }); + */ + + if (AppConfig.supabaseBucket) { + this.attachmentQueue = new PhotoAttachmentQueue({ + powersync: this.powersync, + storage: this.storage, + // Use this to handle download errors where you can use the attachment + // and/or the exception to decide if you want to retry the download + onDownloadError: async (attachment: AttachmentRecord, exception: any) => { + if (exception.toString() === 'StorageApiError: Object not found') { + return { retry: false }; + } + + return { retry: true }; + } + }); + this.attachmentPdfQueue = new PdfAttachmentQueue({ + powersync: this.powersync, + storage: this.storage, + // Use this to handle download errors where you can use the attachment + // and/or the exception to decide if you want to retry the download + onDownloadError: async (attachment: AttachmentRecord, exception: any) => { + if (exception.toString() === 'StorageApiError: Object not found') { + return { retry: false }; + } + + return { retry: true }; + } + }); + } + } + + async init() { + await this.powersync.init(); + await this.powersync.connect(this.supabaseConnector); + + if (this.attachmentQueue) { + await this.attachmentQueue.init(); + } + if (this.attachmentPdfQueue) { + await this.attachmentPdfQueue.init(); + } + } + } + + export const system = new System(); + + export const SystemContext = React.createContext(system); + export const useSystem = () => React.useContext(SystemContext); + ``` + + + +# Usage Example + +The newly created `attachmentPdfQueue` can now be used in a component by using the `useSystem` hook created in [step-3](#step-3-initialize-the-pdfattachmentqueue-class) above + +The code snippet below illustrates how a pdf could be saved when pressing a button. It uses a [DocumentPicker](https://www.npmjs.com/package/react-native-document-picker) UI component +to allow the user to select a pdf. When the button is pressed, `savePdf` is called. + +The `saveAttachment` method in the `PdfAttachmentQueue` class expects a base64 encoded string. We can therefore use +[react-native-fs](https://www.npmjs.com/package/react-native-fs) to read the file and return the base64 encoded string which is passed to `saveAttachment`. + +If your use-case generates a pdf file, ensure that you return a base64 encoded string. + + + +```typescript +import DocumentPicker from 'react-native-document-picker'; +import RNFS from 'react-native-fs'; + +// Within some component + +// useSystem is imported from system.ts +const system = useSystem(); + +const savePdf = async (id: string) => { + if (system.attachmentPdfQueue) { + const res = await DocumentPicker.pick({ + type: [DocumentPicker.types.pdf] + }); + + console.log(`Selected PDF: ${res[0].uri}`); + const base64 = await RNFS.readFile(res[0].uri, 'base64'); + const { id: attachmentId } = await system.attachmentPdfQueue.saveAttachment(base64); + + await system.powersync.execute(`UPDATE ${TODO_TABLE} SET pdf_id = ? WHERE id = ?`, [attachmentId, id]); + } +}; + +