Skip to main content

Introduction

The current version of the To-Do List demo app implements a PhotoAttachmentQueue class which enables photo attachments (specifically 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:

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:
ALTER TABLE public.todos ADD COLUMN pdf_id text NULL;
You can now update the AppSchema to include the newly created column.
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 found in the demo app. The highlighted lines indicate which lines have been updated. For more information on attachments, see the attachments package.
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<AttachmentRecord>): Promise<AttachmentRecord> {
        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<AttachmentRecord> {
        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.
// 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.
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.
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.
system.ts
import '@azure/core-asynciterator-polyfill';

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

import { type AttachmentRecord } from '@powersync/attachments';

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';

createBaseLogger().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 above The code snippet below illustrates how a pdf could be saved when pressing a button. It uses a DocumentPicker 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 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.
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]);  
  }  
};

<Button title="Select PDF" onPress={savePdf} />

Notes

Although this tutorial adds a new pdf_id column, the approach you should take strongly depends on your requirements. An alternative approach could be to replace the photo_id with an attachment_id and have one AttachmentQueue class that handles all attachment types instead of having a class per attachment type.
I